diff --git a/v3/go.mod b/v3/go.mod index de762d609a5..335d61b5d5a 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -12,6 +12,7 @@ require ( github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/huh v0.8.0 github.com/coder/websocket v1.8.14 + github.com/ebitengine/purego v0.10.1 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e github.com/go-ole/go-ole v1.3.0 github.com/godbus/dbus/v5 v5.2.2 diff --git a/v3/go.sum b/v3/go.sum index 67cf8059700..4fe4a8a5c36 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -139,6 +139,8 @@ github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucV github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= diff --git a/v3/internal/assetserver/webview/mainthread_linux.go b/v3/internal/assetserver/webview/mainthread_linux.go index 9ce2ed657e1..94f0f7382f6 100644 --- a/v3/internal/assetserver/webview/mainthread_linux.go +++ b/v3/internal/assetserver/webview/mainthread_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android +//go:build linux && cgo && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/mainthread_linux_purego.go b/v3/internal/assetserver/webview/mainthread_linux_purego.go new file mode 100644 index 00000000000..f385f863fc6 --- /dev/null +++ b/v3/internal/assetserver/webview/mainthread_linux_purego.go @@ -0,0 +1,190 @@ +//go:build linux && purego && !android + +package webview + +// CGO-free (purego) port of mainthread_linux.go. +// +// The C implementation used a statically allocated GMutex to protect the +// dispatch-enabled flag and a per-call GMutex/GCond pair to block the worker; +// here a Go sync.Mutex plays the former role and a per-call done channel the +// latter. The trampoline scheduled onto the GLib main context is a purego +// callback created exactly once (purego callback slots are a process-wide, +// never-freed resource, so a per-call NewCallback would leak them). + +import ( + "fmt" + "sync" + + "github.com/ebitengine/purego" +) + +// ---------------------------------------------------------------------------- +// Library loading helpers (shared with the other *_linux_purego.go files) +// ---------------------------------------------------------------------------- + +// dlopenWebviewLib loads the first of the given soname candidates. The +// versioned name is tried first; the unversioned name usually only exists when +// -dev packages are installed. In practice pkg/application has already loaded +// and validated these same libraries before any request can arrive, so a +// failure here is a safety net, not primary UX — panic with a clear message. +func dlopenWebviewLib(names ...string) uintptr { + var lastErr error + for _, name := range names { + handle, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err == nil && handle != 0 { + return handle + } + if err != nil { + lastErr = err + } + } + panic(fmt.Sprintf("wails: assetserver/webview: failed to load required library %s: %v", names[0], lastErr)) +} + +// mustRegisterWebviewFunc binds fptr to the named symbol in lib, panicking if +// the symbol is missing (same rationale as dlopenWebviewLib: pkg/application +// validated the installed library versions before any request can arrive). +func mustRegisterWebviewFunc(fptr any, lib uintptr, name string) { + sym, err := purego.Dlsym(lib, name) + if err != nil || sym == 0 { + panic(fmt.Sprintf("wails: assetserver/webview: failed to resolve required symbol %s: %v", name, err)) + } + purego.RegisterFunc(fptr, sym) +} + +var ( + webviewGLibOnce sync.Once + webviewLibGLib uintptr + + // void g_main_context_invoke(GMainContext *context, GSourceFunc function, + // gpointer data). Pass 0 for the context to target the default (GTK) main + // context. + g_main_context_invoke func(context uintptr, function uintptr, data uintptr) +) + +// ensureGLib lazily dlopens libglib-2.0 and binds the symbols this file needs. +// Lazy (rather than init-time) so that merely importing the package cannot +// panic on a system without GLib. +func ensureGLib() { + webviewGLibOnce.Do(func() { + webviewLibGLib = dlopenWebviewLib("libglib-2.0.so.0", "libglib-2.0.so") + mustRegisterWebviewFunc(&g_main_context_invoke, webviewLibGLib, "g_main_context_invoke") + }) +} + +// ---------------------------------------------------------------------------- +// Main-thread dispatch +// ---------------------------------------------------------------------------- + +// webviewDispatchMu serializes the enabled-check plus scheduling in +// invokeOnMainSync against the flag clear in DisableMainThreadDispatch. +// Without it a worker could read webviewMainDispatchEnabled == true, then have +// the flag flipped before it scheduled its source, and still queue work onto +// the now-dead main loop — blocking forever. +var webviewDispatchMu sync.Mutex + +// webviewMainDispatchEnabled gates whether invokeOnMainSync may schedule work +// onto the GTK main loop. It starts enabled and is cleared once the loop has +// stopped (see DisableMainThreadDispatch). It is only ever read or written +// while holding webviewDispatchMu. +var webviewMainDispatchEnabled = true + +type mainSyncCall struct { + fn func() + done chan struct{} +} + +var ( + mainSyncMu sync.Mutex + mainSyncNextID uintptr + mainSyncCalls = map[uintptr]*mainSyncCall{} +) + +// mainSyncTrampoline runs on the GTK main thread (scheduled via +// g_main_context_invoke). It invokes the Go callback identified by data, then +// signals the waiting worker: runMainSyncCallback closes the call's done +// channel only after the callback has finished, so the waiter cannot proceed +// (and drop the call record) until the work has completed — the Go analogue of +// the C version signalling the per-call GCond while holding its GMutex. +var mainSyncTrampoline = purego.NewCallback(func(data uintptr) uintptr { + runMainSyncCallback(data) + return 0 // G_SOURCE_REMOVE +}) + +// runMainSyncCallback is the Go twin of the cgo webviewMainThreadCallback +// export: it looks up the pending call by id, removes it from the registry, +// runs it, and releases the waiting worker. +func runMainSyncCallback(id uintptr) { + mainSyncMu.Lock() + call := mainSyncCalls[id] + delete(mainSyncCalls, id) + mainSyncMu.Unlock() + + if call == nil { + return + } + if call.fn != nil { + call.fn() + } + close(call.done) +} + +// invokeOnMainSync runs fn on the GTK main thread and blocks until it returns. +// It is safe to call from any goroutine, including the main thread itself. +// +// WebKit2GTK objects may only be touched on the thread running the GTK main +// loop (g_application_run). Asset-server responses are produced on worker +// goroutines, so the WebKit calls that complete a request must hop here first. +// The wait is safe because webkit_uri_scheme_request_finish_with_response +// returns before the response stream is drained (WebKit reads it +// asynchronously), so the main loop never blocks waiting on the worker. +// +// If the caller is already the main thread, g_main_context_invoke runs the +// trampoline inline, so the done channel is closed before the wait begins and +// the receive completes immediately without deadlocking. +func invokeOnMainSync(fn func()) { + ensureGLib() + + mainSyncMu.Lock() + mainSyncNextID++ + id := mainSyncNextID + call := &mainSyncCall{fn: fn, done: make(chan struct{})} + mainSyncCalls[id] = call + mainSyncMu.Unlock() + + // The enabled-check and the g_main_context_invoke that acts on it must be + // atomic with respect to DisableMainThreadDispatch. Holding + // webviewDispatchMu across both means a worker either schedules onto a live + // loop or sees the loop already stopped — it can never schedule onto a loop + // that stops in between (which would block it here forever). The trampoline + // only touches the per-call registry and done channel, never + // webviewDispatchMu, so when g_main_context_invoke runs it inline + // (main-thread caller) there is no self-deadlock. + webviewDispatchMu.Lock() + if !webviewMainDispatchEnabled { + // The GTK main loop has stopped: a scheduled source would never run. The + // loop is no longer iterating, so the cross-thread race that makes + // main-thread confinement necessary is gone — running the callback inline + // on the worker lets in-flight asset requests drain during shutdown + // instead of wedging. See #5631 (review question 5). + webviewDispatchMu.Unlock() + runMainSyncCallback(id) + return + } + g_main_context_invoke(0, mainSyncTrampoline, id) + webviewDispatchMu.Unlock() + + <-call.done +} + +// DisableMainThreadDispatch marks the GTK main loop as stopped. After it is +// called, invokeOnMainSync runs callbacks inline on the calling goroutine +// instead of scheduling them onto the now-dead main loop, so asset-server +// workers that complete a request during shutdown cannot block forever waiting +// for a source that will never be serviced. The application layer calls this +// once g_application_run has returned. See issue #5631. +func DisableMainThreadDispatch() { + webviewDispatchMu.Lock() + webviewMainDispatchEnabled = false + webviewDispatchMu.Unlock() +} diff --git a/v3/internal/assetserver/webview/mainthread_linux_test.go b/v3/internal/assetserver/webview/mainthread_linux_test.go index 672b103bfd4..f1d32fff048 100644 --- a/v3/internal/assetserver/webview/mainthread_linux_test.go +++ b/v3/internal/assetserver/webview/mainthread_linux_test.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android +//go:build linux && cgo && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/mainthread_testsupport_linux.go b/v3/internal/assetserver/webview/mainthread_testsupport_linux.go index 4f29d3b3472..c255d49b57d 100644 --- a/v3/internal/assetserver/webview/mainthread_testsupport_linux.go +++ b/v3/internal/assetserver/webview/mainthread_testsupport_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android +//go:build linux && cgo && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/request_linux.go b/v3/internal/assetserver/webview/request_linux.go index 9858e28e3c2..20af0131f69 100644 --- a/v3/internal/assetserver/webview/request_linux.go +++ b/v3/internal/assetserver/webview/request_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !gtk3 && !android +//go:build linux && cgo && !gtk3 && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/request_linux_gtk3.go b/v3/internal/assetserver/webview/request_linux_gtk3.go index 70031b3c87a..318ddc660a3 100644 --- a/v3/internal/assetserver/webview/request_linux_gtk3.go +++ b/v3/internal/assetserver/webview/request_linux_gtk3.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && gtk3 && !android +//go:build linux && cgo && gtk3 && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/request_linux_purego.go b/v3/internal/assetserver/webview/request_linux_purego.go new file mode 100644 index 00000000000..6ac688ea5c9 --- /dev/null +++ b/v3/internal/assetserver/webview/request_linux_purego.go @@ -0,0 +1,108 @@ +//go:build linux && purego && !gtk3 && !android + +package webview + +// CGO-free (purego) port of request_linux.go. + +import ( + "io" + "net/http" + "unsafe" + + "github.com/ebitengine/purego" +) + +// unrefRequestOnMain runs on the GTK main thread (scheduled via +// g_main_context_invoke) and drops the reference taken in NewRequest. Created +// once as a package variable: purego callback slots are a process-wide, +// never-freed resource, so a per-call NewCallback would leak them. +var unrefRequestOnMain = purego.NewCallback(func(data uintptr) uintptr { + if data != 0 { + g_object_unref(data) + } + return 0 // G_SOURCE_REMOVE +}) + +// releaseRequestOnMainThread schedules the WebKitURISchemeRequest unref on the +// GTK main context. Close() runs on the assetserver goroutine, and dropping +// what may be the last reference finalizes a WebKit GObject — only safe on the +// UI thread (see #5557). +func releaseRequestOnMainThread(request uintptr) { + if request == 0 { + return + } + g_main_context_invoke(0, unrefRequestOnMain, request) +} + +func NewRequest(webKitURISchemeRequest unsafe.Pointer) Request { + ensureWebviewLibs() + + webkitReq := uintptr(webKitURISchemeRequest) + g_object_ref(webkitReq) + + req := &request{req: webkitReq} + return newRequestFinalizer(req) +} + +var _ Request = &request{} + +type request struct { + req uintptr + + header http.Header + body io.ReadCloser + rw *responseWriter +} + +func (r *request) URL() (string, error) { + // Reading the URI touches the WebKit-owned request on the GTK main loop; + // this runs on a worker goroutine, so it must hop to the main thread. + // See mainthread_linux_purego.go and issue #5631. + var uri string + invokeOnMainSync(func() { + uri = goString(webkit_uri_scheme_request_get_uri(r.req)) + }) + return uri, nil +} + +func (r *request) Method() (string, error) { + return webkitURISchemeRequestGetHTTPMethod(r.req), nil +} + +func (r *request) Header() (http.Header, error) { + if r.header != nil { + return r.header, nil + } + + r.header = webkitURISchemeRequestGetHTTPHeaders(r.req) + return r.header, nil +} + +func (r *request) Body() (io.ReadCloser, error) { + if r.body != nil { + return r.body, nil + } + + r.body = webkitURISchemeRequestGetHTTPBody(r.req) + + return r.body, nil +} + +func (r *request) Response() ResponseWriter { + if r.rw != nil { + return r.rw + } + + r.rw = &responseWriter{req: r.req} + return r.rw +} + +func (r *request) Close() error { + var err error + if r.body != nil { + err = r.body.Close() + } + r.Response().Finish() + releaseRequestOnMainThread(r.req) + return err +} diff --git a/v3/internal/assetserver/webview/responsewriter_linux.go b/v3/internal/assetserver/webview/responsewriter_linux.go index 488bde07656..ee0007b6835 100644 --- a/v3/internal/assetserver/webview/responsewriter_linux.go +++ b/v3/internal/assetserver/webview/responsewriter_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !gtk3 && !android +//go:build linux && cgo && !gtk3 && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/responsewriter_linux_gtk3.go b/v3/internal/assetserver/webview/responsewriter_linux_gtk3.go index aa4c4912d02..e00585800e0 100644 --- a/v3/internal/assetserver/webview/responsewriter_linux_gtk3.go +++ b/v3/internal/assetserver/webview/responsewriter_linux_gtk3.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && gtk3 && !android +//go:build linux && cgo && gtk3 && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/responsewriter_linux_purego.go b/v3/internal/assetserver/webview/responsewriter_linux_purego.go new file mode 100644 index 00000000000..6be061f1b2e --- /dev/null +++ b/v3/internal/assetserver/webview/responsewriter_linux_purego.go @@ -0,0 +1,150 @@ +//go:build linux && purego && !gtk3 && !android + +package webview + +// CGO-free (purego) port of responsewriter_linux.go. + +import ( + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "syscall" +) + +var ( + webviewAssetErrorQuarkOnce sync.Once + webviewAssetErrorQuarkID uint32 +) + +// webviewAssetErrorQuark returns a stable GError domain for asset-server +// failures. The domain string is interned exactly once (sync.Once), the purego +// equivalent of the cgo g_quark_from_static_string over a static string +// literal, so it never leaks. Interning the per-request error message instead +// (the previous behaviour) grew the global quark table unboundedly on +// long-running apps, since GQuarks are never freed. +func webviewAssetErrorQuark() uint32 { + webviewAssetErrorQuarkOnce.Do(func() { + webviewAssetErrorQuarkID = g_quark_from_string("wails-webview-assetserver") + }) + return webviewAssetErrorQuarkID +} + +type responseWriter struct { + req uintptr + + header http.Header + wroteHeader bool + finished bool + code int + + w io.WriteCloser + wErr error +} + +func (rw *responseWriter) Code() int { + return rw.code +} + +func (rw *responseWriter) Header() http.Header { + if rw.header == nil { + rw.header = http.Header{} + } + return rw.header +} + +func (rw *responseWriter) Write(buf []byte) (int, error) { + if rw.finished { + return 0, errResponseFinished + } + + rw.WriteHeader(http.StatusOK) + if rw.wErr != nil { + return 0, rw.wErr + } + return rw.w.Write(buf) +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.code = code + if rw.wroteHeader || rw.finished { + return + } + rw.wroteHeader = true + + contentLength := int64(-1) + if sLen := rw.Header().Get(HeaderContentLength); sLen != "" { + if pLen, _ := strconv.ParseInt(sLen, 10, 64); pLen > 0 { + contentLength = pLen + } + } + + rFD, w, err := pipe() + if err != nil { + rw.finishWithError(http.StatusInternalServerError, fmt.Errorf("unable to open pipe: %s", err)) + return + } + rw.w = w + + // webkitURISchemeRequestFinish wraps the read end of the pipe in a + // GUnixInputStream and completes the request on the GTK main thread; the + // stream is created and released there too. See webkit_linux_purego.go and + // #5631. + if err := webkitURISchemeRequestFinish(rw.req, code, rw.Header(), rFD, contentLength); err != nil { + rw.finishWithError(http.StatusInternalServerError, fmt.Errorf("unable to finish request: %s", err)) + return + } +} + +func (rw *responseWriter) Finish() error { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusNotImplemented) + } + + if rw.finished { + return nil + } + rw.finished = true + if rw.w != nil { + rw.w.Close() + } + return nil +} + +func (rw *responseWriter) finishWithError(code int, err error) { + if rw.w != nil { + rw.w.Close() + rw.w = &nopCloser{io.Discard} + } + rw.wErr = err + + msg := err.Error() + + // webkit_uri_scheme_request_finish_error touches the WebKit-owned request + // on the GTK main loop; this runs on an asset-server worker goroutine, so + // it must hop to the main thread. See mainthread_linux_purego.go and issue + // #5631. + invokeOnMainSync(func() { + gerr := g_error_new_literal(webviewAssetErrorQuark(), int32(code), msg) + webkit_uri_scheme_request_finish_error(rw.req, gerr) + g_error_free(gerr) + }) +} + +type nopCloser struct { + io.Writer +} + +func (nopCloser) Close() error { return nil } + +func pipe() (r int, w *os.File, err error) { + var p [2]int + e := syscall.Pipe2(p[0:], 0) + if e != nil { + return 0, nil, fmt.Errorf("pipe2: %s", e) + } + + return p[0], os.NewFile(uintptr(p[1]), "|1"), nil +} diff --git a/v3/internal/assetserver/webview/webkit_linux.go b/v3/internal/assetserver/webview/webkit_linux.go index 524eab12fd4..049ec66785f 100644 --- a/v3/internal/assetserver/webview/webkit_linux.go +++ b/v3/internal/assetserver/webview/webkit_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !gtk3 && !android +//go:build linux && cgo && !gtk3 && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/webkit_linux_gtk3.go b/v3/internal/assetserver/webview/webkit_linux_gtk3.go index ec79f1b19eb..ac91524e3f8 100644 --- a/v3/internal/assetserver/webview/webkit_linux_gtk3.go +++ b/v3/internal/assetserver/webview/webkit_linux_gtk3.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && gtk3 && !android +//go:build linux && cgo && gtk3 && !android && !purego package webview diff --git a/v3/internal/assetserver/webview/webkit_linux_purego.go b/v3/internal/assetserver/webview/webkit_linux_purego.go new file mode 100644 index 00000000000..5ebff2767bb --- /dev/null +++ b/v3/internal/assetserver/webview/webkit_linux_purego.go @@ -0,0 +1,298 @@ +//go:build linux && purego && !gtk3 && !android + +package webview + +// CGO-free (purego) port of webkit_linux.go. Every WebKit/GObject/libsoup +// function is dlopen(3)ed at runtime and bound with purego.RegisterFunc. +// +// Conventions (shared with the pkg/application purego backend): +// - All C pointers are uintptr. +// - gboolean is int32 on the C side; compare returns with != 0. +// - C string ARGS are declared as Go string (purego marshals them). +// - Const char* RETURNS must not be freed — declare uintptr and copy with +// goString. + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" + "unsafe" +) + +const Webkit2MinMinorVersion = 0 + +// SOUP_MESSAGE_HEADERS_RESPONSE from libsoup's SoupMessageHeadersType enum. +const soupMessageHeadersResponse = 1 + +// soupMessageHeadersIter mirrors SoupMessageHeadersIter: an opaque struct of +// three pointers, allocated by the caller and initialised by +// soup_message_headers_iter_init. +type soupMessageHeadersIter struct { + _ [3]uintptr +} + +// gError mirrors GError: guint32 domain, gint32 code, char *message. +type gError struct { + domain uint32 + code int32 + message uintptr +} + +// ---------------------------------------------------------------------------- +// Library loading +// ---------------------------------------------------------------------------- + +var webviewLibsOnce sync.Once + +var ( + // glib + g_free func(uintptr) + g_error_free func(gErr uintptr) + g_error_new_literal func(domain uint32, code int32, message string) uintptr + g_quark_from_string func(str string) uint32 + + // gobject + g_object_ref func(obj uintptr) uintptr + g_object_unref func(obj uintptr) + + // gio (GUnixInputStream lives in libgio's UNIX API) + g_unix_input_stream_new func(fd int32, closeFD int32) uintptr + g_input_stream_read_all func(stream, buffer, count, bytesRead, cancellable, gErr uintptr) int32 + g_input_stream_close func(stream, cancellable, gErr uintptr) int32 + + // webkit + webkit_uri_scheme_request_get_uri func(req uintptr) uintptr // const return, do NOT free + webkit_uri_scheme_request_get_http_method func(req uintptr) uintptr // const return, do NOT free + webkit_uri_scheme_request_get_http_headers func(req uintptr) uintptr + webkit_uri_scheme_request_get_http_body func(req uintptr) uintptr + webkit_uri_scheme_response_new func(stream uintptr, streamLength int64) uintptr + webkit_uri_scheme_response_set_status func(resp uintptr, statusCode uint32, reasonPhrase string) + webkit_uri_scheme_response_set_content_type func(resp uintptr, contentType string) + webkit_uri_scheme_response_set_http_headers func(resp uintptr, headers uintptr) + webkit_uri_scheme_request_finish_with_response func(req uintptr, resp uintptr) + webkit_uri_scheme_request_finish_error func(req uintptr, gErr uintptr) + + // soup + soup_message_headers_new func(headersType int32) uintptr + soup_message_headers_append func(hdrs uintptr, name string, value string) + soup_message_headers_iter_init func(iter uintptr, hdrs uintptr) + soup_message_headers_iter_next func(iter uintptr, name uintptr, value uintptr) int32 +) + +// ensureWebviewLibs lazily dlopens the GObject/Gio/WebKitGTK/libsoup libraries +// and binds every function this package needs. pkg/application has already +// loaded and validated these same libraries before any request can arrive, so +// a failure here (panic, see mainthread_linux_purego.go helpers) is a safety +// net, not primary UX. +func ensureWebviewLibs() { + webviewLibsOnce.Do(func() { + ensureGLib() + + libGObject := dlopenWebviewLib("libgobject-2.0.so.0", "libgobject-2.0.so") + libGio := dlopenWebviewLib("libgio-2.0.so.0", "libgio-2.0.so") + libWebKit := dlopenWebviewLib("libwebkitgtk-6.0.so.4", "libwebkitgtk-6.0.so") + libSoup := dlopenWebviewLib("libsoup-3.0.so.0", "libsoup-3.0.so") + + mustRegisterWebviewFunc(&g_free, webviewLibGLib, "g_free") + mustRegisterWebviewFunc(&g_error_free, webviewLibGLib, "g_error_free") + mustRegisterWebviewFunc(&g_error_new_literal, webviewLibGLib, "g_error_new_literal") + mustRegisterWebviewFunc(&g_quark_from_string, webviewLibGLib, "g_quark_from_string") + + mustRegisterWebviewFunc(&g_object_ref, libGObject, "g_object_ref") + mustRegisterWebviewFunc(&g_object_unref, libGObject, "g_object_unref") + + mustRegisterWebviewFunc(&g_unix_input_stream_new, libGio, "g_unix_input_stream_new") + mustRegisterWebviewFunc(&g_input_stream_read_all, libGio, "g_input_stream_read_all") + mustRegisterWebviewFunc(&g_input_stream_close, libGio, "g_input_stream_close") + + mustRegisterWebviewFunc(&webkit_uri_scheme_request_get_uri, libWebKit, "webkit_uri_scheme_request_get_uri") + mustRegisterWebviewFunc(&webkit_uri_scheme_request_get_http_method, libWebKit, "webkit_uri_scheme_request_get_http_method") + mustRegisterWebviewFunc(&webkit_uri_scheme_request_get_http_headers, libWebKit, "webkit_uri_scheme_request_get_http_headers") + mustRegisterWebviewFunc(&webkit_uri_scheme_request_get_http_body, libWebKit, "webkit_uri_scheme_request_get_http_body") + mustRegisterWebviewFunc(&webkit_uri_scheme_response_new, libWebKit, "webkit_uri_scheme_response_new") + mustRegisterWebviewFunc(&webkit_uri_scheme_response_set_status, libWebKit, "webkit_uri_scheme_response_set_status") + mustRegisterWebviewFunc(&webkit_uri_scheme_response_set_content_type, libWebKit, "webkit_uri_scheme_response_set_content_type") + mustRegisterWebviewFunc(&webkit_uri_scheme_response_set_http_headers, libWebKit, "webkit_uri_scheme_response_set_http_headers") + mustRegisterWebviewFunc(&webkit_uri_scheme_request_finish_with_response, libWebKit, "webkit_uri_scheme_request_finish_with_response") + mustRegisterWebviewFunc(&webkit_uri_scheme_request_finish_error, libWebKit, "webkit_uri_scheme_request_finish_error") + + mustRegisterWebviewFunc(&soup_message_headers_new, libSoup, "soup_message_headers_new") + mustRegisterWebviewFunc(&soup_message_headers_append, libSoup, "soup_message_headers_append") + mustRegisterWebviewFunc(&soup_message_headers_iter_init, libSoup, "soup_message_headers_iter_init") + mustRegisterWebviewFunc(&soup_message_headers_iter_next, libSoup, "soup_message_headers_iter_next") + }) +} + +// goString copies a NUL-terminated C string. The pointer is not freed — use +// this for const char* returns, which we must not free. +func goString(c uintptr) string { + if c == 0 { + return "" + } + ptr := *(*unsafe.Pointer)(unsafe.Pointer(&c)) + n := 0 + for *(*byte)(unsafe.Add(ptr, n)) != 0 { + n++ + } + return string(unsafe.Slice((*byte)(ptr), n)) +} + +// ---------------------------------------------------------------------------- +// WebKit URI scheme request helpers +// ---------------------------------------------------------------------------- + +func webkitURISchemeRequestGetHTTPMethod(req uintptr) string { + // Reading request metadata touches the WebKit-owned request object, which + // belongs to the GTK main loop; this runs on a worker goroutine, so it must + // hop to the main thread. See mainthread_linux_purego.go and issue #5631. + var method string + invokeOnMainSync(func() { + method = goString(webkit_uri_scheme_request_get_http_method(req)) + }) + return strings.ToUpper(method) +} + +func webkitURISchemeRequestGetHTTPHeaders(req uintptr) http.Header { + h := http.Header{} + // Reading and iterating the request's libsoup headers touches WebKit-owned + // state on the GTK main loop; this runs on a worker goroutine, so it must hop + // to the main thread. See mainthread_linux_purego.go and issue #5631. + invokeOnMainSync(func() { + hdrs := webkit_uri_scheme_request_get_http_headers(req) + + var iter soupMessageHeadersIter + soup_message_headers_iter_init(uintptr(unsafe.Pointer(&iter)), hdrs) + + var name uintptr + var value uintptr + + for soup_message_headers_iter_next(uintptr(unsafe.Pointer(&iter)), uintptr(unsafe.Pointer(&name)), uintptr(unsafe.Pointer(&value))) != 0 { + h.Add(goString(name), goString(value)) + } + }) + return h +} + +func webkitURISchemeRequestFinish(req uintptr, code int, header http.Header, rFD int, streamLength int64) error { + // Completing the request touches WebKit/libsoup objects owned by the GTK + // main loop, but this runs on an asset-server worker goroutine. WebKit2GTK + // is not thread-safe, so the whole sequence must hop to the main thread. + // + // The response input stream is created and unref'd inside the same hop: it is + // ref-taken by webkit_uri_scheme_response_new on the main thread, so creating + // and releasing our reference here too keeps every refcount operation on a + // single thread. Previously the stream was built and unref'd on the worker + // while WebKit took its ref on the main thread, splitting the stream's + // refcount across threads. See mainthread_linux_purego.go and issue #5631. + invokeOnMainSync(func() { + stream := g_unix_input_stream_new(int32(rFD), 1) + defer g_object_unref(stream) + + resp := webkit_uri_scheme_response_new(stream, streamLength) + defer g_object_unref(resp) + + webkit_uri_scheme_response_set_status(resp, uint32(code), http.StatusText(code)) + + webkit_uri_scheme_response_set_content_type(resp, header.Get(HeaderContentType)) + + // Ownership of hdrs is transferred to the response by + // webkit_uri_scheme_response_set_http_headers (transfer full), so we must + // not unref it here — doing so frees the headers while WebKit/libsoup still + // reference them, crashing in soup_message_headers_iter_next on render. + hdrs := soup_message_headers_new(soupMessageHeadersResponse) + for name, values := range header { + for _, value := range values { + soup_message_headers_append(hdrs, name, value) + } + } + + webkit_uri_scheme_response_set_http_headers(resp, hdrs) + + webkit_uri_scheme_request_finish_with_response(req, resp) + }) + return nil +} + +func webkitURISchemeRequestGetHTTPBody(req uintptr) io.ReadCloser { + // Fetching the request body stream touches the WebKit-owned request on the + // GTK main loop; this runs on a worker goroutine, so it must hop to the main + // thread. See mainthread_linux_purego.go and issue #5631. + var stream uintptr + invokeOnMainSync(func() { + stream = webkit_uri_scheme_request_get_http_body(req) + }) + if stream == 0 { + return http.NoBody + } + return &webkitRequestBody{stream: stream} +} + +type webkitRequestBody struct { + stream uintptr + closed bool +} + +func (r *webkitRequestBody) Read(p []byte) (int, error) { + if r.closed { + return 0, io.ErrClosedPipe + } + + // io.Reader allows a zero-length read; taking &p[0] on an empty slice would + // panic, so return early before touching the backing array. + if len(p) == 0 { + return 0, nil + } + + content := unsafe.Pointer(&p[0]) + contentLen := len(p) + + var n uintptr + var gErr uintptr + var res int32 + // Reading the WebKit-owned request body stream must happen on the GTK main + // loop thread; this runs on a worker goroutine. See issue #5631. + invokeOnMainSync(func() { + res = g_input_stream_read_all(r.stream, uintptr(content), uintptr(contentLen), uintptr(unsafe.Pointer(&n)), 0, uintptr(unsafe.Pointer(&gErr))) + }) + if res == 0 { + return 0, formatGError("stream read failed", gErr) + } else if n == 0 { + return 0, io.EOF + } + return int(n), nil +} + +func (r *webkitRequestBody) Close() error { + if r.closed { + return nil + } + r.closed = true + + var err error + var gErr uintptr + // Closing and unref-ing the WebKit-owned request body stream finalizes a + // GObject tied to the GTK main loop; this runs on a worker goroutine, so it + // must hop to the main thread. See issue #5631. + invokeOnMainSync(func() { + if g_input_stream_close(r.stream, 0, uintptr(unsafe.Pointer(&gErr))) == 0 { + err = formatGError("stream close failed", gErr) + } + g_object_unref(r.stream) + }) + r.stream = 0 + return err +} + +func formatGError(msg string, gErr uintptr, args ...any) error { + if gErr != 0 { + // GError layout: guint32 domain, gint32 code, char *message. + e := (*gError)(unsafe.Pointer(gErr)) + if e.message != 0 { + msg += ": " + goString(e.message) + g_error_free(gErr) + } + } + return fmt.Errorf(msg, args...) +} diff --git a/v3/internal/operatingsystem/webkit_linux.go b/v3/internal/operatingsystem/webkit_linux.go index a58f7acd4dc..3f9d7c8e2af 100644 --- a/v3/internal/operatingsystem/webkit_linux.go +++ b/v3/internal/operatingsystem/webkit_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !gtk3 && !android +//go:build linux && cgo && !gtk3 && !android && !purego package operatingsystem diff --git a/v3/internal/operatingsystem/webkit_linux_gtk3.go b/v3/internal/operatingsystem/webkit_linux_gtk3.go index 9e8bc9a5177..0e0d881dbee 100644 --- a/v3/internal/operatingsystem/webkit_linux_gtk3.go +++ b/v3/internal/operatingsystem/webkit_linux_gtk3.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && gtk3 && !android +//go:build linux && cgo && gtk3 && !android && !purego package operatingsystem diff --git a/v3/internal/operatingsystem/webkit_linux_purego.go b/v3/internal/operatingsystem/webkit_linux_purego.go new file mode 100644 index 00000000000..e43c507cc91 --- /dev/null +++ b/v3/internal/operatingsystem/webkit_linux_purego.go @@ -0,0 +1,81 @@ +//go:build linux && purego && !gtk3 && !android + +package operatingsystem + +import ( + "fmt" + "sync" + + "github.com/ebitengine/purego" +) + +type WebkitVersion struct { + Major uint + Minor uint + Micro uint +} + +var ( + webkitVersionOnce sync.Once + + webkit_get_major_version func() uint32 + webkit_get_minor_version func() uint32 + webkit_get_micro_version func() uint32 +) + +// loadWebkitVersionFuncs binds the three version getters from the runtime +// WebKitGTK library. On failure the funcs stay nil and GetWebkitVersion +// reports 0.0.0 — this is diagnostic-only code (wails doctor), so a missing +// library must not crash it. +func loadWebkitVersionFuncs() { + webkitVersionOnce.Do(func() { + var lib uintptr + for _, name := range []string{"libwebkitgtk-6.0.so.4", "libwebkitgtk-6.0.so"} { + handle, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err == nil && handle != 0 { + lib = handle + break + } + } + if lib == 0 { + return + } + reg := func(fptr any, name string) { + sym, err := purego.Dlsym(lib, name) + if err == nil && sym != 0 { + purego.RegisterFunc(fptr, sym) + } + } + reg(&webkit_get_major_version, "webkit_get_major_version") + reg(&webkit_get_minor_version, "webkit_get_minor_version") + reg(&webkit_get_micro_version, "webkit_get_micro_version") + }) +} + +func GetWebkitVersion() WebkitVersion { + loadWebkitVersionFuncs() + if webkit_get_major_version == nil || + webkit_get_minor_version == nil || + webkit_get_micro_version == nil { + return WebkitVersion{} + } + return WebkitVersion{ + Major: uint(webkit_get_major_version()), + Minor: uint(webkit_get_minor_version()), + Micro: uint(webkit_get_micro_version()), + } +} + +func (v WebkitVersion) String() string { + return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Micro) +} + +func (v WebkitVersion) IsAtLeast(major int, minor int, micro int) bool { + if v.Major != uint(major) { + return v.Major > uint(major) + } + if v.Minor != uint(minor) { + return v.Minor > uint(minor) + } + return v.Micro >= uint(micro) +} diff --git a/v3/pkg/application/BUGS_FOUND.md b/v3/pkg/application/BUGS_FOUND.md new file mode 100644 index 00000000000..a4a32f6c869 --- /dev/null +++ b/v3/pkg/application/BUGS_FOUND.md @@ -0,0 +1,129 @@ +# Bugs found in the cgo Linux backend during the purego port + +The purego port's goal is behavioural parity with the cgo GTK4/WebKitGTK-6.0 +backend — but not bug-for-bug parity. Translating `linux_cgo.go`/`linux_cgo.c` +line by line surfaced the defects below. Each is FIXED in the purego backend; +the cgo backend still has them (fixing it is follow-up work, tracked here so +the fixes aren't lost). + +## 1. `appName()` frees a GLib-owned string (undefined behaviour) + +`linux_cgo.go:129-133`: `g_get_application_name()` returns a pointer owned by +GLib ("the returned string is owned by GLib and must not be modified or +freed"), but the cgo code does `defer C.free(unsafe.Pointer(name))`. Freeing +memory allocated by GLib's allocator with libc `free()` — and freeing memory +that GLib still owns — is a use-after-free waiting to happen (GLib returns the +same pointer on the next call). It also crashes outright when the application +name was never set (`g_get_application_name` may return NULL, and the +subsequent `C.GoString(nil)` masks it while `free(NULL)` is a no-op — the +real damage is when it is non-NULL). + +**purego fix:** copy the string, never free it (`linux_purego.go: appName`). + +## 2. `getWindows()` dereferences a NULL GList head + +`linux_cgo.go:207-217`: the loop over `gtk_application_get_windows()` reads +`windows.data` before the nil check. With zero windows the function returns +NULL and the first dereference crashes. Reachable via `App.Hide()`/`Show()` +(hideAllWindows/showAllWindows) and `linuxApp.isVisible()` when called before +any window exists or after all have closed. + +**purego fix:** standard nil-checked loop (`linux_purego.go: getWindows`). + +## 3. Clipboard sync-read machinery is not reentrant + +`linux_cgo.c:924-956`: `clipboard_get_text_sync()` parks the result in two +STATIC globals (`clipboard_sync_result`, `clipboard_sync_done`) while spinning +a nested `g_main_context_iteration` loop. If a second clipboard read starts +while the first is still iterating (the nested loop dispatches arbitrary main +-loop sources — including another dispatched `clipboardGet`), the two calls +overwrite each other's flags: one caller can return the other's text, or spin +forever after its result was consumed. + +**purego fix:** per-call state keyed by a handle in a mutex-guarded map +(`linux_purego_callbacks.go: clipboardGetTextSync`). + +## 4. `gtkSignalToMenuItem` map is read/written without synchronisation + +`linux_cgo.go:80,269-290,405-408`: `attachMenuHandler` writes the map from +whatever goroutine builds the menu; `menuActionActivated` reads it on the GTK +main thread. Unsynchronised concurrent map access is a fatal runtime error +("concurrent map read and map write") — a menu rebuilt via `Menu.Update()` +while the user clicks an item can kill the process. + +**purego fix:** RWMutex around all accesses (`linux_purego.go`). + +## 5. `menuItemActions` / `menuItemCounters` maps likewise unguarded + +`linux_cgo.go:257-267,327-348`: same pattern as #4 — `generateActionName` +writes `menuItemActions` during menu construction, `menuItemSetChecked`/ +`menuItemSetDisabled`/`setMenuItemAccelerator` read it from API goroutines +(only `menuItemIds` got a mutex in cgo). Same fatal-crash class. + +**purego fix:** one RWMutex (`menuItemsLock`) guards all three maps. + +## 6. File-dialog callback can deadlock the GTK main loop on >100 selections + +`linux_cgo.go:1767-1794`: `fileDialogCallback` runs on the GTK main thread +(GtkFileDialog async completion) and sends each selected path into a channel +with a fixed buffer of 100 (`runChooserDialog`). Selecting more than 100 files +blocks the main thread on the 101st send until the consumer drains — and if +the consumer is itself waiting on anything main-thread-bound, the app +deadlocks permanently. + +**purego fix:** hand the collected paths to a goroutine for delivery, so the +main thread never blocks (`linux_purego.go: fileDialogCallback`). + +## 7. System tray silently requires cgo for no reason + +`systemtray_linux.go` (shared file) carried a vestigial `import "C"` with zero +`C.` usages. It's a pure-Go dbus StatusNotifier implementation, but the import +made the file cgo-only, which would have silently dropped the tray from any +CGO_ENABLED=0 build. + +**Fix (applies to both backends):** removed the import — this one is fixed in +the shared file itself, not just in the purego twin. + +## 8. Message dialog: closing via the titlebar leaves a zombie window (and a use-after-free) + +`linux_cgo.c:787-793`: `on_message_dialog_close` (the "close-request" handler) +delivers the cancel result, frees the `MessageDialogData`, and returns `TRUE`. +For GTK4's `close-request`, returning TRUE means "handled — do NOT close", so +the dialog window stays on screen forever. Worse, its buttons are still wired +to the now-freed `MessageDialogData`; clicking one after the X-button is a +use-after-free. + +**purego fix:** the handler returns FALSE so GTK's default destroys the +window, and the dialog state lives in a Go registry keyed by handle — a late +button click after teardown resolves to nil and is ignored +(`linux_purego_callbacks.go: onMessageDialogClosePtr`). + +## Parity observation (crash present in BOTH backends, not fixed here) + +On a headless X server without DRI (Xvfb; `/dev/dri/*` inaccessible, WebKit on +the software/EGL-fallback path), closing a second window while the first stays +open kills the process with an X error — `BadDrawable, request_code 14 +(X_GetGeometry)` — followed in the cgo build by glibc `free(): corrupted +unsorted chunks`. Verified on Ubuntu 26.04 / GTK 4.22.2 / WebKitGTK 2.52.3 +with byte-identical reproduction steps against both the purego and the cgo +binaries: both die the same way, so this is an upstream WebKitGTK teardown +issue in the no-DRI rendering path, not a port defect. Main-window lifecycle +(open → interact → close → clean exit 0) is unaffected. Worth re-testing on a +real GPU-backed session before chasing it in Wails. + +## Non-bug deltas (deliberate) + +- **Fractional monitor scale on older GTK4:** the cgo build hard-requires + `gdk_monitor_get_scale` (GTK 4.14+) at link time, so it cannot run against + older GTK4. The purego backend resolves it optionally and falls back to the + integer `gdk_monitor_get_scale_factor` at runtime (`monitorScale()`), + extending the supported range downward instead of crashing at startup. +- **Missing-library UX:** a cgo binary fails at exec time with the dynamic + linker's terse "cannot open shared object file". The purego backend reports + every missing library/symbol with per-distro install hints + (`linux_purego_lib.go: loadLinuxLibraries`). +- **Per-menu-item `MenuItemData` heap blocks leak in cgo** (`linux_cgo.c`, + `g_new0(MenuItemData,1)` never freed; freed only implicitly at exit). The + purego port passes the item id as the callback's data word, so the + allocation doesn't exist. Listed as a delta, not a fix, because the leak is + bounded by menu size. diff --git a/v3/pkg/application/PUREGO_LINUX.md b/v3/pkg/application/PUREGO_LINUX.md new file mode 100644 index 00000000000..d7f5d1b42ab --- /dev/null +++ b/v3/pkg/application/PUREGO_LINUX.md @@ -0,0 +1,101 @@ +# CGO-free Linux backend (purego) + +This is a port of the Wails v3 **Linux** backend (GTK4 + WebKitGTK 6.0) that +runs with `CGO_ENABLED=0` by loading the system libraries at runtime through +[`github.com/ebitengine/purego`](https://github.com/ebitengine/purego) +instead of compiling C via cgo. + +## Building + +```sh +CGO_ENABLED=0 go build -tags purego ./... +``` + +The `purego` build tag selects this backend; without it the existing cgo +backend is used (unchanged). The two are mutually exclusive: every cgo Linux +file carries `&& !purego`, and every file here carries `&& purego`. +Cross-compiling from any host works (`GOOS=linux GOARCH=amd64 CGO_ENABLED=0`), +since no C toolchain or Linux sysroot is involved. + +The GTK3 variant (`-tags gtk3`) is **not** supported in combination with +`purego` — the purego backend targets the same default stack as the default +cgo build: GTK4 and WebKitGTK 6.0. + +## Design + +- `linux_purego_lib.go` — the foundation. `dlopen(3)`s GLib/GObject/Gio/ + GTK4/WebKitGTK-6.0/JavaScriptCore/libsoup (with per-distro soname + fallbacks) and binds every C function to a typed Go function variable via + `purego.RegisterFunc`. A missing library or symbol produces one aggregate, + actionable error (which package to install per distro) instead of a + dynamic-linker one-liner or a nil-pointer crash. +- `linux_purego_callbacks.go` — the pure-Go port of `linux_cgo.c`: GTK signal + trampolines (`purego.NewCallback`, a fixed set created once — never per + window/item/call), main-thread dispatch via `g_idle_add`, the GAction menu + machinery, GTK4 file/message dialogs, drag-and-drop controllers, clipboard, + and the X11 helpers (window move/position, always-on-top) resolved with + `dlsym(RTLD_DEFAULT)` from GTK's own X11 backend — no libX11 link, no-ops on + Wayland, exactly like the cgo backend. +- `linux_purego.go` — the port of `linux_cgo.go`: the full shim function + surface the shared Linux files (`webview_window_linux.go`, + `menu_linux.go`, `dialogs_linux.go`, …) compile against. +- `application_linux_purego.go` — the port of `application_linux.go` + (GApplication lifecycle, dbus theme monitoring, screens cache). +- `global_shortcut_linux_x11_purego.go` — XGrabKey global shortcuts via + dlopen'ed libX11 (the Wayland portal backend was already pure Go and is now + shared between backends via a `(cgo || purego)` tag, as are the dbus theme + monitor and permission helpers). +- `internal/assetserver/webview/*_linux_purego.go` — the WebKit URI-scheme + request/response plumbing, preserving the #5631/#5668 main-thread + confinement design (every WebKit/GObject touch hops to the GTK main loop; + after the loop stops, dispatch is disabled and completions run inline). +- The signal-handler fix (SA_ONSTACK re-application, issue #5527, including + the deliberate SIGUSR1 exemption for JavaScriptCore's GC) is preserved by + calling libc `sigaction` through purego. + +## Runtime requirements and capability guards + +Runtime-loaded libraries (sonames tried in order): + +| Library | Soname | Debian/Ubuntu package | +|---|---|---| +| GLib/GObject/Gio | `libglib-2.0.so.0`, `libgobject-2.0.so.0`, `libgio-2.0.so.0` | `libglib2.0-0` | +| GTK4 | `libgtk-4.so.1` | `libgtk-4-1` | +| WebKitGTK | `libwebkitgtk-6.0.so.4` | `libwebkitgtk-6.0-4` | +| JavaScriptCore | `libjavascriptcoregtk-6.0.so.1` | `libjavascriptcoregtk-6.0-1` | +| libsoup | `libsoup-3.0.so.0` | `libsoup-3.0-0` | +| libX11 (optional) | `libX11.so.6` | only needed for X11 global shortcuts | + +Minimum versions: GTK 4.10 (GtkFileDialog) and WebKitGTK 2.40, matching the +cgo backend's compile floor. Because symbols are resolved by name at runtime, +there is no compile-time SDK ceiling: newer-than-floor functions are resolved +with `registerOptional`/`haveSymbol` and nil-checked before use. Current +examples: + +- `gdk_monitor_get_scale` (GTK 4.14+, fractional scaling) falls back to the + integer `gdk_monitor_get_scale_factor` on older GTK4 — the cgo build refuses + to start on those systems; the purego build degrades gracefully. +- The GDK X11 symbols (`gdk_x11_display_get_xdisplay`, …) are optional: they + don't exist in Wayland-only GTK builds, and every X11 helper no-ops without + them. + +When adding new library calls, follow the conventions documented at the top +of `linux_purego_lib.go` — in particular: purego cannot call variadic C +functions (use the `_value`/`_with_properties` variants), and any symbol newer +than the floor must be registered as optional and guarded. + +## Bugs fixed relative to the cgo backend + +The port is behaviour-parity, not bug-parity: defects found in the cgo +backend while translating it are fixed here and catalogued in +[BUGS_FOUND.md](BUGS_FOUND.md) (invalid free of a GLib-owned string, NULL +GList dereference, clipboard reentrancy, unsynchronised menu maps, a +file-dialog main-loop deadlock, a message-dialog zombie-window/UAF, and a +vestigial `import "C"` that silently made the system tray cgo-only). + +## Verified + +- `GOOS=linux GOARCH=amd64|arm64 CGO_ENABLED=0 go build -tags purego ./...` +- The default cgo build is unaffected (`go build ./...` on a Linux box). +- Runtime validation on a real Linux desktop: see the session notes / PR + description for the exact checks performed. diff --git a/v3/pkg/application/application_linux.go b/v3/pkg/application/application_linux.go index db707e9e834..85c88aecdc9 100644 --- a/v3/pkg/application/application_linux.go +++ b/v3/pkg/application/application_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !gtk3 && !android && !server +//go:build linux && cgo && !gtk3 && !android && !server && !purego package application diff --git a/v3/pkg/application/application_linux_dbus.go b/v3/pkg/application/application_linux_dbus.go index 04f02d671f0..d9f78a7af0b 100644 --- a/v3/pkg/application/application_linux_dbus.go +++ b/v3/pkg/application/application_linux_dbus.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android && !server +//go:build linux && (cgo || purego) && !android && !server package application diff --git a/v3/pkg/application/application_linux_gtk3.go b/v3/pkg/application/application_linux_gtk3.go index e61fd53261f..76a6450d516 100644 --- a/v3/pkg/application/application_linux_gtk3.go +++ b/v3/pkg/application/application_linux_gtk3.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && gtk3 && !android && !server +//go:build linux && cgo && gtk3 && !android && !server && !purego package application diff --git a/v3/pkg/application/application_linux_purego.go b/v3/pkg/application/application_linux_purego.go new file mode 100644 index 00000000000..04862a9a83d --- /dev/null +++ b/v3/pkg/application/application_linux_purego.go @@ -0,0 +1,359 @@ +//go:build linux && purego && !gtk3 && !android && !server + +package application + +// CGO-free port of application_linux.go. The only structural difference: +// a purego build has no compile-time GTK/WebKit headers, so the +// "compiled with" version constants don't exist — the runtime versions +// (gtk_get_*_version / webkit_get_*_version) are reported instead. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/wailsapp/wails/v3/internal/operatingsystem" + "github.com/wailsapp/wails/v3/pkg/events" +) + +var invalidAppNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) +var leadingDigits = regexp.MustCompile(`^[0-9]+`) + +func sanitizeAppName(name string) string { + name = invalidAppNameChars.ReplaceAllString(name, "_") + name = leadingDigits.ReplaceAllString(name, "_$0") + for strings.Contains(name, "__") { + name = strings.ReplaceAll(name, "__", "_") + } + name = strings.Trim(name, "_") + if name == "" { + name = "wailsapp" + } + return strings.ToLower(name) +} + +func init() { + // Disable DMA-BUF renderer on any session type with NVIDIA to prevent blank windows and + // "Error 71 (Protocol error)" crashes. NVIDIA proprietary drivers fail gbm_bo_map() when + // importing DMA-BUF, causing blank/white screens on both X11 and Wayland. + // See: https://bugs.webkit.org/show_bug.cgi?id=262607 + // See: https://github.com/wailsapp/wails/issues/4985 + if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") == "" && isNVIDIAGPU() { + _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") + } +} + +func isNVIDIAGPU() bool { + if _, err := os.Stat("/sys/module/nvidia"); err == nil { + return true + } + return false +} + +type linuxApp struct { + application pointer + parent *App + + activated chan struct{} + activatedOnce sync.Once + + windowMap map[windowPointer]uint + windowMapLock sync.Mutex + + theme string + + icon pointer +} + +func (a *linuxApp) GetFlags(options Options) map[string]any { + if options.Flags == nil { + options.Flags = make(map[string]any) + } + return options.Flags +} + +func (a *linuxApp) name() string { + return appName() +} + +func (a *linuxApp) run() error { + a.parent.Event.OnApplicationEvent(events.Linux.ApplicationStartup, func(evt *ApplicationEvent) { + if err := a.processAndCacheScreens(); err != nil { + a.parent.handleError(err) + } + }) + a.setupCommonEvents() + // Theme changes are already monitored by listenForSystemThemeChanges via init(); + // it uses the portal-standard org.freedesktop.appearance namespace. + a.monitorPowerEvents() + return appRun(a.application) +} + +func (a *linuxApp) destroy() { + if !globalApplication.shouldQuit() { + return + } + globalApplication.cleanup() + appDestroy(a.application) +} + +func (a *linuxApp) getApplicationMenu() *Menu { + return nil +} + +func (a *linuxApp) setApplicationMenu(menu *Menu) {} + +func (a *linuxApp) hide() { + a.hideAllWindows() +} + +func (a *linuxApp) show() { + a.showAllWindows() +} + +func (a *linuxApp) on(eventID uint) { +} + +func (a *linuxApp) isOnMainThread() bool { + return isOnMainThread() +} + +func gtkRuntimeVersion() string { + return fmt.Sprintf("%d.%d.%d", + gtk_get_major_version(), + gtk_get_minor_version(), + gtk_get_micro_version()) +} + +func webkitRuntimeVersion() string { + return fmt.Sprintf("%d.%d.%d", + webkit_get_major_version(), + webkit_get_minor_version(), + webkit_get_micro_version()) +} + +func (a *linuxApp) appendGTKVersion(result map[string]string) { + result["GTK"] = gtkRuntimeVersion() + result["WebKit"] = webkitRuntimeVersion() +} + +func (a *linuxApp) init(_ *App, options Options) { + osInfo, _ := operatingsystem.Info() + a.parent.info("Running GTK %s (loaded at runtime via purego)", gtkRuntimeVersion()) + a.parent.info("Running WebKitGTK %s (loaded at runtime via purego)", webkitRuntimeVersion()) + a.parent.info("Using %s", osInfo.Name) + + if options.Icon != nil { + a.setIcon(options.Icon) + } + + go listenForSystemThemeChanges(a) +} + +func listenForSystemThemeChanges(a *linuxApp) { + conn, err := dbus.SessionBus() + if err != nil { + a.parent.error("failed to connect to session bus: %v", err) + return + } + + if err = conn.AddMatchSignal( + dbus.WithMatchInterface("org.freedesktop.portal.Settings"), + dbus.WithMatchMember("SettingChanged"), + ); err != nil { + return + } + + c := make(chan *dbus.Signal, 10) + conn.Signal(c) + + for s := range c { + if len(s.Body) < 3 { + continue + } + namespace, ok := s.Body[0].(string) + if !ok || namespace != "org.freedesktop.appearance" { + continue + } + key, ok := s.Body[1].(string) + if !ok || key != "color-scheme" { + continue + } + processApplicationEvent(uint(events.Linux.SystemThemeChanged), nilPointer) + } +} + +func (a *linuxApp) registerWindow(window pointer, id uint) { + a.windowMapLock.Lock() + a.windowMap[windowPointer(window)] = id + a.windowMapLock.Unlock() +} + +func (a *linuxApp) unregisterWindow(window windowPointer) { + a.windowMapLock.Lock() + delete(a.windowMap, window) + remainingWindows := len(a.windowMap) + a.windowMapLock.Unlock() + + if remainingWindows == 0 && !a.parent.options.Linux.DisableQuitOnLastWindowClosed { + a.destroy() + } +} + +func newPlatformApp(parent *App) *linuxApp { + name := sanitizeAppName(parent.options.Name) + app := &linuxApp{ + parent: parent, + application: appNew(name), + activated: make(chan struct{}), + windowMap: map[windowPointer]uint{}, + } + + if parent.options.Linux.ProgramName != "" { + setProgramName(parent.options.Linux.ProgramName) + } + + return app +} + +func (a *linuxApp) markActivated() { + a.activatedOnce.Do(func() { + close(a.activated) + }) +} + +func (a *linuxApp) waitForActivation() { + <-a.activated +} + +func (a *linuxApp) getIconForFile(filename string) ([]byte, error) { + if filename == "" { + return nil, nil + } + + ext := filepath.Ext(filename) + iconMap := map[string]string{ + ".txt": "text-x-generic", + ".pdf": "application-pdf", + ".doc": "x-office-document", + ".docx": "x-office-document", + ".xls": "x-office-spreadsheet", + ".xlsx": "x-office-spreadsheet", + ".ppt": "x-office-presentation", + ".pptx": "x-office-presentation", + ".zip": "package-x-generic", + ".tar": "package-x-generic", + ".gz": "package-x-generic", + ".jpg": "image-x-generic", + ".jpeg": "image-x-generic", + ".png": "image-x-generic", + ".gif": "image-x-generic", + ".mp3": "audio-x-generic", + ".wav": "audio-x-generic", + ".mp4": "video-x-generic", + ".avi": "video-x-generic", + ".html": "text-html", + ".css": "text-css", + ".js": "text-javascript", + ".json": "text-json", + ".xml": "text-xml", + } + + iconName := "application-x-generic" + if name, ok := iconMap[ext]; ok { + iconName = name + } + + return getIconBytes(iconName) +} + +func getIconBytes(iconName string) ([]byte, error) { + return nil, fmt.Errorf("icon lookup is not currently implemented for the GTK4 build path; build with -tags gtk3 for the legacy implementation") +} + +func (a *linuxApp) isDarkMode() bool { + conn, err := dbus.SessionBus() + if err != nil { + return false + } + + obj := conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop") + call := obj.Call("org.freedesktop.portal.Settings.Read", 0, "org.freedesktop.appearance", "color-scheme") + if call.Err != nil { + return false + } + + var result dbus.Variant + if err := call.Store(&result); err != nil { + return false + } + + innerVariant, ok := result.Value().(dbus.Variant) + if !ok { + return false + } + colorScheme, ok := innerVariant.Value().(uint32) + if !ok { + return false + } + + return colorScheme == 1 +} + +func (a *linuxApp) getAccentColor() string { + return "rgb(0,122,255)" +} + +func (a *linuxApp) isVisible() bool { + windows := a.getWindows() + for _, window := range windows { + if gtk_widget_is_visible(uintptr(window)) != 0 { + return true + } + } + return false +} + +func getNativeApplication() *linuxApp { + return globalApplication.impl.(*linuxApp) +} + +// logPlatformInfo logs the platform information to the console +func (a *App) logPlatformInfo() { + info, err := operatingsystem.Info() + if err != nil { + a.error("error getting OS info: %w", err) + return + } + + platformInfo := info.AsLogSlice() + platformInfo = append(platformInfo, "GTK", gtkRuntimeVersion()) + platformInfo = append(platformInfo, "WebKitGTK", webkitRuntimeVersion()) + + a.info("Platform Info:", platformInfo...) +} + +func (a *App) platformEnvironment() map[string]any { + result := map[string]any{} + // No compile-time versions exist in a purego build; the libraries are + // resolved at runtime. + result["gtk4-compiled"] = "n/a (purego runtime binding)" + result["gtk4-runtime"] = gtkRuntimeVersion() + result["webkitgtk6-compiled"] = "n/a (purego runtime binding)" + result["webkitgtk6-runtime"] = webkitRuntimeVersion() + + result["compositor"] = detectCompositor() + result["wayland"] = isWayland() + result["focusFollowsMouse"] = detectFocusFollowsMouse() + + return result +} + +func fatalHandler(errFunc func(error)) { + // Stub for windows function + return +} diff --git a/v3/pkg/application/global_shortcut_linux.go b/v3/pkg/application/global_shortcut_linux.go index 79dea869a6a..c41bc9ad8b5 100644 --- a/v3/pkg/application/global_shortcut_linux.go +++ b/v3/pkg/application/global_shortcut_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android && !server +//go:build linux && (cgo || purego) && !android && !server package application diff --git a/v3/pkg/application/global_shortcut_linux_portal.go b/v3/pkg/application/global_shortcut_linux_portal.go index ab906a31546..f9384e27386 100644 --- a/v3/pkg/application/global_shortcut_linux_portal.go +++ b/v3/pkg/application/global_shortcut_linux_portal.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android && !server +//go:build linux && (cgo || purego) && !android && !server package application diff --git a/v3/pkg/application/global_shortcut_linux_x11.go b/v3/pkg/application/global_shortcut_linux_x11.go index aee0f305689..113886698a7 100644 --- a/v3/pkg/application/global_shortcut_linux_x11.go +++ b/v3/pkg/application/global_shortcut_linux_x11.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android && !server +//go:build linux && cgo && !android && !server && !purego package application diff --git a/v3/pkg/application/global_shortcut_linux_x11_purego.go b/v3/pkg/application/global_shortcut_linux_x11_purego.go new file mode 100644 index 00000000000..f31ffc331aa --- /dev/null +++ b/v3/pkg/application/global_shortcut_linux_x11_purego.go @@ -0,0 +1,475 @@ +//go:build linux && purego && !android && !server + +package application + +// CGO-free twin of global_shortcut_linux_x11.go. It implements X11 global +// shortcuts via XGrabKey on a dedicated Display connection with its own event +// loop, loading libX11 at runtime through purego instead of linking it. +// +// This file is fully self-contained (like the cgo original): it must not +// reference identifiers from linux_purego_lib.go / linux_purego_callbacks.go / +// linux_purego.go, because those are only built under !gtk3 while this file +// builds for every purego Linux configuration. + +import ( + "fmt" + "runtime" + "sync" + "sync/atomic" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" +) + +// X11 keyboard state mask bits (from X.h) that we treat as significant +// modifiers. LockMask (CapsLock) and Mod2Mask (NumLock) are deliberately +// excluded so that shortcuts fire regardless of those locks. +const ( + x11ShiftMask = 1 << 0 // ShiftMask + x11ControlMask = 1 << 2 // ControlMask + x11Mod1Mask = 1 << 3 // Mod1Mask (Alt) + x11Mod4Mask = 1 << 6 // Mod4Mask (Super) +) + +const x11SignificantMask = x11ShiftMask | x11ControlMask | x11Mod1Mask | x11Mod4Mask + +// Xlib constants used below (from X.h). +const ( + gsX11LockMask = 1 << 1 // LockMask (CapsLock) + gsX11Mod2Mask = 1 << 4 // Mod2Mask (NumLock) + gsX11KeyPress = 2 // KeyPress event type + gsX11GrabModeAsync = 1 // GrabModeAsync + gsX11False = 0 // False + gsX11NoSymbol = 0 // NoSymbol +) + +// The lock modifiers (CapsLock, NumLock) alter the event state, so each shortcut +// must be grabbed for every combination of them or it will not fire while a +// lock is engaged. +var gsX11LockMasks = [4]uint32{0, gsX11LockMask, gsX11Mod2Mask, gsX11LockMask | gsX11Mod2Mask} + +// XKeyEvent field offsets inside the 192-byte XEvent union on 64-bit Linux: +// type int32 @0, ..., state uint32 @80, keycode uint32 @84. +const ( + gsX11EventTypeOffset = 0 + gsX11KeyEventStateOff = 80 + gsX11KeyEventKeycodeOff = 84 +) + +// gsX11Funcs holds the libX11 entry points this backend needs, bound at +// runtime via purego. All Xlib pointer types (Display*, Window, KeySym, +// XErrorHandler) are uintptr. +type gsX11Funcs struct { + openDisplay func(name uintptr) uintptr // Display *XOpenDisplay(char*) + closeDisplay func(display uintptr) int32 // int XCloseDisplay(Display*) + setErrorHandler func(handler uintptr) uintptr // XErrorHandler XSetErrorHandler(XErrorHandler) + stringToKeysym func(name string) uintptr // KeySym XStringToKeysym(char*) + keysymToKeycode func(display uintptr, keysym uintptr) uint8 // KeyCode XKeysymToKeycode(Display*, KeySym) + defaultRootWindow func(display uintptr) uintptr // Window XDefaultRootWindow(Display*) + grabKey func(display uintptr, keycode int32, modifiers uint32, window uintptr, ownerEvents, pointerMode, keyboardMode int32) int32 // int XGrabKey(...) + ungrabKey func(display uintptr, keycode int32, modifiers uint32, window uintptr) int32 // int XUngrabKey(...) + sync func(display uintptr, discard int32) int32 // int XSync(Display*, Bool) + pending func(display uintptr) int32 // int XPending(Display*) + nextEvent func(display uintptr, event uintptr) int32 // int XNextEvent(Display*, XEvent*) + connectionNumber func(display uintptr) int32 // int XConnectionNumber(Display*) +} + +var ( + gsX11Once sync.Once + gsX11 *gsX11Funcs + gsX11Err error +) + +// gsX11GrabError is the grab error flag, set by the X error handler installed +// on our dedicated Display connection. Because every Xlib call this file makes +// is serialized onto a single goroutine (the event loop), plain reads/writes +// would suffice; atomics are used out of caution since the handler runs inside +// Xlib. +var gsX11GrabError int32 + +// gsX11ErrorHandler is the X error handler callback, created exactly once as a +// package var (purego callback slots are a finite resource). It mirrors the +// cgo grabErrorHandler: record that an error happened and swallow it. +var gsX11ErrorHandler = purego.NewCallback(func(display, event uintptr) uintptr { + atomic.StoreInt32(&gsX11GrabError, 1) + return 0 +}) + +// gsX11Load loads libX11 and binds the required symbols, once. +func gsX11Load() (*gsX11Funcs, error) { + gsX11Once.Do(func() { + handle, err := purego.Dlopen("libX11.so.6", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + var err2 error + handle, err2 = purego.Dlopen("libX11.so", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err2 != nil { + gsX11Err = fmt.Errorf("libX11 could not be loaded: %w", err) + return + } + } + fns := &gsX11Funcs{} + bind := func(fptr any, name string) { + if gsX11Err != nil { + return + } + sym, err := purego.Dlsym(handle, name) + if err != nil || sym == 0 { + gsX11Err = fmt.Errorf("libX11 is missing symbol %s: %w", name, err) + return + } + purego.RegisterFunc(fptr, sym) + } + bind(&fns.openDisplay, "XOpenDisplay") + bind(&fns.closeDisplay, "XCloseDisplay") + bind(&fns.setErrorHandler, "XSetErrorHandler") + bind(&fns.stringToKeysym, "XStringToKeysym") + bind(&fns.keysymToKeycode, "XKeysymToKeycode") + bind(&fns.defaultRootWindow, "XDefaultRootWindow") + bind(&fns.grabKey, "XGrabKey") + bind(&fns.ungrabKey, "XUngrabKey") + bind(&fns.sync, "XSync") + bind(&fns.pending, "XPending") + bind(&fns.nextEvent, "XNextEvent") + bind(&fns.connectionNumber, "XConnectionNumber") + if gsX11Err == nil { + gsX11 = fns + } + }) + return gsX11, gsX11Err +} + +// x11Binding records what was grabbed so it can be matched against incoming +// events and ungrabbed later. +type x11Binding struct { + keycode uint + modMask uint +} + +// x11GlobalShortcuts implements globalShortcutImpl on X11 using XGrabKey. All +// Xlib calls are funnelled onto a single event-loop goroutine; register and +// unregister hand work to it over opCh and wake it through a self-pipe. This +// keeps Xlib single-threaded (no XInitThreads) while still letting the loop +// block in select(). +type x11GlobalShortcuts struct { + manager *GlobalShortcutManager + + lib *gsX11Funcs + display uintptr + wakeR int + wakeW int + opCh chan func() + + mu sync.RWMutex + bindings map[int]x11Binding // id -> grabbed keycode/mask + match map[x11Binding]int // keycode/mask -> id (for event lookup) + startErr error +} + +func newX11GlobalShortcuts(manager *GlobalShortcutManager) globalShortcutImpl { + g := &x11GlobalShortcuts{ + manager: manager, + opCh: make(chan func(), 16), + bindings: make(map[int]x11Binding), + match: make(map[x11Binding]int), + } + + lib, err := gsX11Load() + if err != nil { + g.startErr = fmt.Errorf("X11 global shortcuts unavailable: %w", err) + return g + } + g.lib = lib + + g.display = lib.openDisplay(0) + if g.display == 0 { + g.startErr = fmt.Errorf("could not open an X11 display (global shortcuts via X require an X11 session)") + return g + } + lib.setErrorHandler(gsX11ErrorHandler) + + fds := make([]int, 2) + if err := syscall.Pipe(fds); err != nil { + lib.closeDisplay(g.display) + g.display = 0 + g.startErr = fmt.Errorf("could not create wake pipe: %w", err) + return g + } + g.wakeR, g.wakeW = fds[0], fds[1] + syscall.SetNonblock(g.wakeR, true) + syscall.SetNonblock(g.wakeW, true) + + go g.eventLoop() + return g +} + +func (g *x11GlobalShortcuts) wake() { + var b [1]byte + _, _ = syscall.Write(g.wakeW, b[:]) +} + +// run executes fn on the event-loop goroutine and waits for it to complete. +func (g *x11GlobalShortcuts) run(fn func()) { + done := make(chan struct{}) + g.opCh <- func() { + fn() + close(done) + } + g.wake() + <-done +} + +// gsX11FdSet / gsX11FdIsSet are FD_SET / FD_ISSET for syscall.FdSet, whose +// Bits array elements are 64-bit words on linux/amd64 and linux/arm64. +func gsX11FdSet(set *syscall.FdSet, fd int) { + set.Bits[fd/64] |= 1 << (uint(fd) % 64) +} + +func gsX11FdIsSet(set *syscall.FdSet, fd int) bool { + return set.Bits[fd/64]&(1<<(uint(fd)%64)) != 0 +} + +// waitForEvent blocks until either an X KeyPress arrives or the wake pipe +// becomes readable. Returns: +// +// 1 -> a KeyPress; keycode and state are filled in +// 0 -> woken via the pipe (caller should service its request queue) +// -1 -> the connection was lost +// +// This is the Go twin of the cgo gsWaitForEvent helper. +func (g *x11GlobalShortcuts) waitForEvent() (r int, keycode, state uint32) { + xfd := int(g.lib.connectionNumber(g.display)) + for { + for g.lib.pending(g.display) > 0 { + // XEvent is a 192-byte union; use an 8-byte-aligned buffer. + var ev [24]uint64 + g.lib.nextEvent(g.display, uintptr(unsafe.Pointer(&ev[0]))) + p := unsafe.Pointer(&ev[0]) + if *(*int32)(unsafe.Add(p, gsX11EventTypeOffset)) == gsX11KeyPress { + keycode = *(*uint32)(unsafe.Add(p, gsX11KeyEventKeycodeOff)) + state = *(*uint32)(unsafe.Add(p, gsX11KeyEventStateOff)) + return 1, keycode, state + } + } + var fds syscall.FdSet + gsX11FdSet(&fds, xfd) + gsX11FdSet(&fds, g.wakeR) + maxfd := xfd + if g.wakeR > maxfd { + maxfd = g.wakeR + } + _, err := syscall.Select(maxfd+1, &fds, nil, nil, nil) + if err != nil { + if err == syscall.EINTR { + continue + } + return -1, 0, 0 + } + if gsX11FdIsSet(&fds, g.wakeR) { + var buf [64]byte + for { + n, rerr := syscall.Read(g.wakeR, buf[:]) + if n <= 0 || rerr != nil { + break + } + } + return 0, 0, 0 + } + } +} + +func (g *x11GlobalShortcuts) eventLoop() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + for { + r, keycode, state := g.waitForEvent() + switch r { + case 0: + g.drainOps() + case 1: + b := x11Binding{keycode: uint(keycode), modMask: uint(state) & x11SignificantMask} + g.mu.RLock() + id, ok := g.match[b] + g.mu.RUnlock() + if ok { + g.manager.dispatch(id) + } + default: + return + } + } +} + +func (g *x11GlobalShortcuts) drainOps() { + for { + select { + case op := <-g.opCh: + op() + default: + return + } + } +} + +func (g *x11GlobalShortcuts) modMask(accel *accelerator) uint { + var mask uint + for _, m := range accel.Modifiers { + switch m { + case CmdOrCtrlKey, ControlKey: + mask |= x11ControlMask + case OptionOrAltKey: + mask |= x11Mod1Mask + case ShiftKey: + mask |= x11ShiftMask + case SuperKey: + mask |= x11Mod4Mask + } + } + return mask +} + +// keycodeForName resolves an X keysym name (e.g. "a", "F1", "Return") to a +// hardware keycode for this display. Returns 0 if unknown. Must be called on +// the event-loop goroutine. +func (g *x11GlobalShortcuts) keycodeForName(name string) uint { + ks := g.lib.stringToKeysym(name) + if ks == gsX11NoSymbol { + return 0 + } + return uint(g.lib.keysymToKeycode(g.display, ks)) +} + +// grabKey grabs keycode+modmask (and every lock-mask variant) on the root +// window. Returns 0 on success, -1 if the grab was refused (BadAccess), which +// happens when another client already holds the combination. Must be called on +// the event-loop goroutine. +func (g *x11GlobalShortcuts) grabKey(keycode, modmask uint32) int { + root := g.lib.defaultRootWindow(g.display) + atomic.StoreInt32(&gsX11GrabError, 0) + for _, lm := range gsX11LockMasks { + g.lib.grabKey(g.display, int32(keycode), modmask|lm, root, gsX11False, gsX11GrabModeAsync, gsX11GrabModeAsync) + } + g.lib.sync(g.display, gsX11False) + if atomic.LoadInt32(&gsX11GrabError) != 0 { + return -1 + } + return 0 +} + +// ungrabKey releases keycode+modmask (and every lock-mask variant) on the root +// window. Must be called on the event-loop goroutine. +func (g *x11GlobalShortcuts) ungrabKey(keycode, modmask uint32) { + root := g.lib.defaultRootWindow(g.display) + for _, lm := range gsX11LockMasks { + g.lib.ungrabKey(g.display, int32(keycode), modmask|lm, root) + } + g.lib.sync(g.display, gsX11False) +} + +func (g *x11GlobalShortcuts) register(id int, accel *accelerator) error { + if g.startErr != nil { + return g.startErr + } + keysymName, ok := x11KeysymNames[accel.Key] + if !ok { + return fmt.Errorf("key %q is not supported as a global shortcut", accel.Key) + } + modMask := g.modMask(accel) + + var regErr error + var binding x11Binding + g.run(func() { + keycode := g.keycodeForName(keysymName) + if keycode == 0 { + regErr = fmt.Errorf("key %q has no keycode on this keyboard", accel.Key) + return + } + if g.grabKey(uint32(keycode), uint32(modMask)) != 0 { + regErr = fmt.Errorf("the shortcut is already registered (possibly by another application)") + return + } + binding = x11Binding{keycode: keycode, modMask: modMask} + }) + if regErr != nil { + return regErr + } + + g.mu.Lock() + g.bindings[id] = binding + g.match[binding] = id + g.mu.Unlock() + return nil +} + +func (g *x11GlobalShortcuts) unregister(id int) error { + g.mu.Lock() + binding, ok := g.bindings[id] + if ok { + delete(g.bindings, id) + delete(g.match, binding) + } + g.mu.Unlock() + if !ok { + return nil + } + g.run(func() { + g.ungrabKey(uint32(binding.keycode), uint32(binding.modMask)) + }) + return nil +} + +func (g *x11GlobalShortcuts) unregisterAll() error { + g.mu.Lock() + bindings := g.bindings + g.bindings = make(map[int]x11Binding) + g.match = make(map[x11Binding]int) + g.mu.Unlock() + if len(bindings) == 0 { + return nil + } + g.run(func() { + for _, b := range bindings { + g.ungrabKey(uint32(b.keycode), uint32(b.modMask)) + } + }) + return nil +} + +// x11KeysymNames maps Wails accelerator key names (already lower-cased by +// parseAccelerator) to X keysym names accepted by XStringToKeysym. Letters and +// digits map to themselves. +var x11KeysymNames = map[string]string{ + "a": "a", "b": "b", "c": "c", "d": "d", "e": "e", "f": "f", "g": "g", + "h": "h", "i": "i", "j": "j", "k": "k", "l": "l", "m": "m", "n": "n", + "o": "o", "p": "p", "q": "q", "r": "r", "s": "s", "t": "t", "u": "u", + "v": "v", "w": "w", "x": "x", "y": "y", "z": "z", + "0": "0", "1": "1", "2": "2", "3": "3", "4": "4", + "5": "5", "6": "6", "7": "7", "8": "8", "9": "9", + // Punctuation + ";": "semicolon", "=": "equal", ",": "comma", "-": "minus", ".": "period", + "/": "slash", "`": "grave", "[": "bracketleft", "\\": "backslash", + "]": "bracketright", "'": "apostrophe", "+": "plus", + // Named keys + "backspace": "BackSpace", + "tab": "Tab", + "return": "Return", + "enter": "Return", + "escape": "Escape", + "space": "space", + "page up": "Prior", + "page down": "Next", + "end": "End", + "home": "Home", + "left": "Left", + "up": "Up", + "right": "Right", + "down": "Down", + "delete": "Delete", + "numlock": "Num_Lock", + // Function keys + "f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6", + "f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12", + "f13": "F13", "f14": "F14", "f15": "F15", "f16": "F16", "f17": "F17", + "f18": "F18", "f19": "F19", "f20": "F20", "f21": "F21", "f22": "F22", + "f23": "F23", "f24": "F24", +} diff --git a/v3/pkg/application/global_shortcut_linux_x11_test.go b/v3/pkg/application/global_shortcut_linux_x11_test.go index 49d6b800429..5147814fbfb 100644 --- a/v3/pkg/application/global_shortcut_linux_x11_test.go +++ b/v3/pkg/application/global_shortcut_linux_x11_test.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android && !server +//go:build linux && cgo && !android && !server && !purego package application diff --git a/v3/pkg/application/global_shortcut_unsupported.go b/v3/pkg/application/global_shortcut_unsupported.go index c50f8a382ab..3ba6917f5a0 100644 --- a/v3/pkg/application/global_shortcut_unsupported.go +++ b/v3/pkg/application/global_shortcut_unsupported.go @@ -1,4 +1,4 @@ -//go:build ios || android || server || (linux && !cgo) +//go:build ios || android || server || (linux && !cgo && !purego) package application diff --git a/v3/pkg/application/linux_cgo.go b/v3/pkg/application/linux_cgo.go index 9c45446449f..f71d10a6195 100644 --- a/v3/pkg/application/linux_cgo.go +++ b/v3/pkg/application/linux_cgo.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !gtk3 && !android && !server +//go:build linux && cgo && !gtk3 && !android && !server && !purego package application diff --git a/v3/pkg/application/linux_cgo_gtk3.go b/v3/pkg/application/linux_cgo_gtk3.go index 3e4f80e685d..8f16cc22077 100644 --- a/v3/pkg/application/linux_cgo_gtk3.go +++ b/v3/pkg/application/linux_cgo_gtk3.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && gtk3 && !android && !server +//go:build linux && cgo && gtk3 && !android && !server && !purego package application diff --git a/v3/pkg/application/linux_purego.go b/v3/pkg/application/linux_purego.go new file mode 100644 index 00000000000..805294db77f --- /dev/null +++ b/v3/pkg/application/linux_purego.go @@ -0,0 +1,1550 @@ +//go:build linux && purego && !gtk3 && !android && !server + +package application + +// CGO-free port of linux_cgo.go: the GTK4/WebKitGTK-6.0 backend driven through +// purego instead of cgo. The function surface (names, signatures, behaviour) +// mirrors the cgo shim exactly so the shared Linux files compile unchanged +// against either backend. +// +// Known cgo bugs are FIXED here rather than ported 1:1 — each fix is recorded +// in BUGS_FOUND.md. + +import ( + "fmt" + "sync" + "unsafe" + + "github.com/wailsapp/wails/v3/internal/assetserver/webview" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// getLinuxWebviewWindow safely extracts a linuxWebviewWindow from a Window interface +func getLinuxWebviewWindow(window Window) *linuxWebviewWindow { + if window == nil { + return nil + } + + webviewWindow, ok := window.(*WebviewWindow) + if !ok { + return nil + } + + lw, ok := webviewWindow.impl.(*linuxWebviewWindow) + if !ok { + return nil + } + + return lw +} + +var ( + // BUGS_FOUND #4: the cgo backend accesses this map from the GTK main + // thread (menuActionActivated) while attachMenuHandler writes it from the + // menu-processing goroutine, with no synchronisation. Guarded here. + gtkSignalToMenuItem = map[uint]*MenuItem{} + gtkSignalToMenuItemLock sync.RWMutex + + mainThreadId uintptr +) + +var ( + registerURIScheme sync.Once + fixSignalHandlers sync.Once +) + +func init() { + linuxLibsErr = loadLinuxLibraries() + if linuxLibsErr == nil { + // Package init runs on the process's main thread, the same thread + // that will run the GTK main loop (matching the cgo init()). + mainThreadId = g_thread_self() + } +} + +func isOnMainThread() bool { + return g_thread_self() == mainThreadId +} + +// implementation below + +func appName() string { + // BUGS_FOUND #1: g_get_application_name returns a string owned by GLib; + // the cgo backend free()s it (undefined behaviour). Copy, don't free. + return goString(g_get_application_name()) +} + +func appNew(name string) pointer { + if linuxLibsErr != nil { + // The GUI libraries are dlopen'ed at runtime; fail with the full + // actionable report (which libraries/symbols, what to install) + // instead of a nil-pointer crash on the first GTK call. + Fatal("%v", linuxLibsErr) + } + + installSignalHandlers() + + appId := fmt.Sprintf("org.wails.%s", name) + return pointer(gtk_application_new(appId, gApplicationDefaultFlags)) +} + +func setProgramName(prgName string) { + g_set_prgname(prgName) +} + +func appRun(app pointer) error { + application := uintptr(app) + g_application_hold(application) + + signalConnect(application, "activate", activateLinuxPtr, 0) + status := g_application_run(application, 0, 0) + // The GTK main loop has stopped. Tell the asset-server webview layer to stop + // marshalling WebKit calls onto it, so any request still being completed on a + // worker goroutine runs inline instead of blocking on a loop that is gone. + // See #5631. + webview.DisableMainThreadDispatch() + g_application_release(application) + g_object_unref(application) + + var err error + if status != 0 { + err = fmt.Errorf("exit code: %d", status) + } + return err +} + +func appDestroy(application pointer) { + g_application_quit(uintptr(application)) +} + +func (w *linuxWebviewWindow) contextMenuSignals(menu pointer) { + // GTK4: GtkPopoverMenu items are wired through the "app" GAction group, + // which is attached to the window in windowNew. The popover's "closed" + // signal (used for cleanup) is connected in showContextMenu, so there + // is nothing to wire up here. +} + +func (w *linuxWebviewWindow) contextMenuShow(menu pointer, data *ContextMenuData) { + // GTK4: present the GMenu model as a GtkPopoverMenu anchored to the + // webview at the click coordinates (which are relative to the webview). + showContextMenu(uintptr(w.webview), uintptr(menu), data.X, data.Y) +} + +func (a *linuxApp) getCurrentWindowID() uint { + window := gtk_application_get_active_window(uintptr(a.application)) + if window == 0 { + return uint(1) + } + a.windowMapLock.Lock() + identifier, ok := a.windowMap[windowPointer(window)] + a.windowMapLock.Unlock() + if ok { + return identifier + } + return uint(1) +} + +func (a *linuxApp) getWindows() []pointer { + result := []pointer{} + // BUGS_FOUND #2: the cgo backend dereferences the returned GList head + // unconditionally; when no window exists the list is NULL and it crashes. + windows := gtk_application_get_windows(uintptr(a.application)) + for windows != nil { + result = append(result, windows.data) + windows = windows.next + } + return result +} + +func (a *linuxApp) hideAllWindows() { + for _, window := range a.getWindows() { + gtk_widget_set_visible(uintptr(window), 0) + } +} + +func (a *linuxApp) showAllWindows() { + for _, window := range a.getWindows() { + gtk_window_present(uintptr(window)) + } +} + +func (a *linuxApp) setIcon(icon []byte) { + // GTK4 removed per-window icon APIs. The application icon is determined by + // the .desktop file's Icon= field at the desktop-integration level. + // No programmatic equivalent exists for setting icons from bytes in GTK4. +} + +func clipboardGet() string { + return clipboardGetTextSync() +} + +func clipboardSet(text string) { + display := gdk_display_get_default() + clip := gdk_display_get_clipboard(display) + gdk_clipboard_set_text(clip, text) +} + +// Menu - GTK4 uses GMenu/GAction instead of GtkMenu + +// BUGS_FOUND #5: in the cgo backend menuItemActions and menuItemCounters are +// plain maps written during menu construction and read from GTK callbacks +// without any locking (only menuItemIds had a mutex). One mutex guards all +// three here. +var ( + menuItemActionCounter uint32 + menuItemActions = make(map[uint]string) + menuItemIds = make(map[pointer]uint) + menuItemCounters = make(map[pointer]int) + menuItemsLock sync.RWMutex +) + +func generateActionName(itemId uint) string { + menuItemsLock.Lock() + defer menuItemsLock.Unlock() + menuItemActionCounter++ + name := fmt.Sprintf("action_%d", menuItemActionCounter) + menuItemActions[itemId] = name + return name +} + +func lookupActionName(itemId uint) (string, bool) { + menuItemsLock.RLock() + defer menuItemsLock.RUnlock() + name, ok := menuItemActions[itemId] + return name, ok +} + +func menuActionActivated(id uint) { + gtkSignalToMenuItemLock.RLock() + item, ok := gtkSignalToMenuItem[id] + gtkSignalToMenuItemLock.RUnlock() + if !ok { + return + } + switch item.itemType { + case text: + menuItemClicked <- item.id + case checkbox: + impl := item.impl.(*linuxMenuItem) + currentState := impl.isChecked() + impl.setChecked(!currentState) + menuItemClicked <- item.id + case radio: + menuItem := item.impl.(*linuxMenuItem) + if !menuItem.isChecked() { + menuItem.setChecked(true) + menuItemClicked <- item.id + } + } +} + +func menuNewSection() pointer { + return pointer(g_menu_new()) +} + +func menuAppendSection(menu *Menu, section pointer) { + if menu.impl == nil { + return + } + impl := menu.impl.(*linuxMenu) + if impl.native == nilPointer { + return + } + g_menu_append_section(uintptr(impl.native), 0, uintptr(section)) +} + +func menuAppendItemToSection(section pointer, item *MenuItem) { + if item.impl == nil { + return + } + menuImpl := item.impl.(*linuxMenuItem) + if menuImpl.native == nilPointer { + return + } + + menuImpl.parentMenu = section + menuImpl.isHidden = item.hidden + + if !item.hidden { + g_menu_append_item(uintptr(section), uintptr(menuImpl.native)) + } +} + +func menuAppend(parent *Menu, menu *MenuItem, hidden bool) { + if parent.impl == nil || menu.impl == nil { + return + } + parentImpl := parent.impl.(*linuxMenu) + menuImpl := menu.impl.(*linuxMenuItem) + if parentImpl.native == nilPointer || menuImpl.native == nilPointer { + return + } + + menuImpl.parentMenu = parentImpl.native + menuImpl.isHidden = hidden + + menuItemsLock.Lock() + menuImpl.menuIndex = menuItemCounters[parentImpl.native] + menuItemCounters[parentImpl.native]++ + menuItemsLock.Unlock() + + if !hidden { + g_menu_append_item(uintptr(parentImpl.native), uintptr(menuImpl.native)) + } +} + +// menuClear removes every item from the menu's native GMenu so that it can be +// rebuilt from scratch on Menu.Update() (#5464). The per-menu append counter is +// reset too, so rebuilt items get fresh 0-based positions (menuIndex is used by +// menu_remove_item for hide/show). This mirrors the GTK3/cgo menuClear. +func menuClear(menu *Menu) { + if menu.impl == nil { + return + } + impl := menu.impl.(*linuxMenu) + if impl.native == nilPointer { + return + } + g_menu_remove_all(uintptr(impl.native)) + menuItemsLock.Lock() + delete(menuItemCounters, impl.native) + menuItemsLock.Unlock() +} + +func menuBarNew() pointer { + gmenu := g_menu_new() + appMenuModel = gmenu + return pointer(gmenu) +} + +func menuNew() pointer { + return pointer(g_menu_new()) +} + +func menuSetSubmenu(item *MenuItem, menu *Menu) { + if item.impl == nil || menu.impl == nil { + return + } + itemImpl := item.impl.(*linuxMenuItem) + menuImpl := menu.impl.(*linuxMenu) + if itemImpl.native == nilPointer || menuImpl.native == nilPointer { + return + } + g_menu_item_set_submenu(uintptr(itemImpl.native), uintptr(menuImpl.native)) +} + +func menuGetRadioGroup(item *linuxMenuItem) *GSList { + return nil +} + +func attachMenuHandler(item *MenuItem) uint { + gtkSignalToMenuItemLock.Lock() + gtkSignalToMenuItem[item.id] = item + gtkSignalToMenuItemLock.Unlock() + return item.id +} + +func menuItemChecked(widget pointer) bool { + if widget == nilPointer { + return false + } + menuItemsLock.RLock() + itemId, exists := menuItemIds[widget] + menuItemsLock.RUnlock() + if !exists { + return false + } + actionName, ok := lookupActionName(itemId) + if !ok { + return false + } + return getActionState(actionName) +} + +func menuItemNew(label string, bitmap []byte) pointer { + return nilPointer +} + +func menuItemNewWithId(label string, bitmap []byte, itemId uint) pointer { + actionName := generateActionName(itemId) + gitem := createMenuItem(label, actionName, itemId) + + menuItemsLock.Lock() + menuItemIds[pointer(gitem)] = itemId + menuItemsLock.Unlock() + return pointer(gitem) +} + +func menuItemDestroy(widget pointer) { + if widget != nilPointer { + g_object_unref(uintptr(widget)) + } +} + +func menuItemSetHidden(item *linuxMenuItem, hidden bool) { + if item.parentMenu == nilPointer { + return + } + if hidden { + g_menu_remove(uintptr(item.parentMenu), int32(item.menuIndex)) + } else { + g_menu_insert_item(uintptr(item.parentMenu), int32(item.menuIndex), uintptr(item.native)) + } +} + +func menuCheckItemNew(label string, bitmap []byte) pointer { + return nilPointer +} + +func menuCheckItemNewWithId(label string, bitmap []byte, itemId uint, checked bool) pointer { + actionName := generateActionName(itemId) + gitem := createCheckMenuItem(label, actionName, itemId, checked) + + menuItemsLock.Lock() + menuItemIds[pointer(gitem)] = itemId + menuItemsLock.Unlock() + return pointer(gitem) +} + +func menuItemSetChecked(widget pointer, checked bool) { + if widget == nilPointer { + return + } + menuItemsLock.RLock() + itemId, exists := menuItemIds[widget] + menuItemsLock.RUnlock() + if !exists { + return + } + actionName, ok := lookupActionName(itemId) + if !ok { + return + } + setActionState(actionName, checked) +} + +func menuItemSetDisabled(widget pointer, disabled bool) { + if widget == nilPointer { + return + } + menuItemsLock.RLock() + itemId, exists := menuItemIds[widget] + menuItemsLock.RUnlock() + if !exists { + return + } + actionName, ok := lookupActionName(itemId) + if !ok { + return + } + setActionEnabled(actionName, !disabled) +} + +func menuItemSetLabel(widget pointer, label string) { + if widget == nilPointer { + return + } + g_menu_item_set_label(uintptr(widget), label) +} + +func menuItemRemoveBitmap(widget pointer) { +} + +func menuItemSetBitmap(widget pointer, bitmap []byte) { +} + +func menuItemSetToolTip(widget pointer, tooltip string) { +} + +func menuItemSignalBlock(widget pointer, handlerId uint, block bool) { +} + +func menuRadioItemNew(group *GSList, label string) pointer { + return nilPointer +} + +func menuRadioItemNewWithId(label string, itemId uint, checked bool) pointer { + actionName := generateActionName(itemId) + gitem := createCheckMenuItem(label, actionName, itemId, checked) + + menuItemsLock.Lock() + menuItemIds[pointer(gitem)] = itemId + menuItemsLock.Unlock() + return pointer(gitem) +} + +func menuRadioItemNewWithGroup(label string, itemId uint, groupId uint, checkedId uint) pointer { + actionName := fmt.Sprintf("radio_group_%d", groupId) + targetValue := fmt.Sprintf("%d", itemId) + initialValue := fmt.Sprintf("%d", checkedId) + + gitem := createRadioMenuItem(label, actionName, targetValue, initialValue, itemId) + + menuItemsLock.Lock() + menuItemIds[pointer(gitem)] = itemId + menuItemsLock.Unlock() + return pointer(gitem) +} + +// Keyboard accelerator support for GTK4 menus + +// namedKeysToGTK maps Wails key names to GDK keysym values +// These are X11 keysym values that GDK uses +var namedKeysToGTK = map[string]uint32{ + "backspace": 0xff08, + "tab": 0xff09, + "return": 0xff0d, + "enter": 0xff0d, + "escape": 0xff1b, + "left": 0xff51, + "right": 0xff53, + "up": 0xff52, + "down": 0xff54, + "space": 0xff80, + "delete": 0xff9f, + "home": 0xff95, + "end": 0xff9c, + "page up": 0xff9a, + "page down": 0xff9b, + "f1": 0xffbe, + "f2": 0xffbf, + "f3": 0xffc0, + "f4": 0xffc1, + "f5": 0xffc2, + "f6": 0xffc3, + "f7": 0xffc4, + "f8": 0xffc5, + "f9": 0xffc6, + "f10": 0xffc7, + "f11": 0xffc8, + "f12": 0xffc9, + "f13": 0xffca, + "f14": 0xffcb, + "f15": 0xffcc, + "f16": 0xffcd, + "f17": 0xffce, + "f18": 0xffcf, + "f19": 0xffd0, + "f20": 0xffd1, + "f21": 0xffd2, + "f22": 0xffd3, + "f23": 0xffd4, + "f24": 0xffd5, + "f25": 0xffd6, + "f26": 0xffd7, + "f27": 0xffd8, + "f28": 0xffd9, + "f29": 0xffda, + "f30": 0xffdb, + "f31": 0xffdc, + "f32": 0xffdd, + "f33": 0xffde, + "f34": 0xffdf, + "f35": 0xffe0, + "numlock": 0xff7f, +} + +// parseKeyGTK converts a Wails key string to a GDK keysym value +func parseKeyGTK(key string) uint32 { + // Check named keys first + if result, found := namedKeysToGTK[key]; found { + return result + } + // For single character keys, convert using gdk_unicode_to_keyval + if len(key) != 1 { + return 0 + } + return gdk_unicode_to_keyval(uint32(key[0])) +} + +// parseModifiersGTK converts Wails modifiers to GDK modifier type +func parseModifiersGTK(modifiers []modifier) uint32 { + var result uint32 + + for _, mod := range modifiers { + switch mod { + case ShiftKey: + result |= gdkShiftMask + case ControlKey, CmdOrCtrlKey: + result |= gdkControlMask + case OptionOrAltKey: + result |= gdkAltMask + case SuperKey: + result |= gdkSuperMask + } + } + return result +} + +// acceleratorToGTK converts a Wails accelerator to GTK key/modifiers +func acceleratorToGTK(accel *accelerator) (uint32, uint32) { + key := parseKeyGTK(accel.Key) + mods := parseModifiersGTK(accel.Modifiers) + return key, mods +} + +// setMenuItemAccelerator sets the keyboard accelerator for a menu item +// This uses gtk_application_set_accels_for_action to register the shortcut +func setMenuItemAccelerator(itemId uint, accel *accelerator) { + if accel == nil { + return + } + + // Look up the action name for this menu item + actionName, ok := lookupActionName(itemId) + if !ok { + return + } + + // Get the GtkApplication pointer + app := getNativeApplication() + if app == nil || app.application == nilPointer { + return + } + + // Convert accelerator to GTK format + key, mods := acceleratorToGTK(accel) + if key == 0 { + return + } + + // Build accelerator string using GTK's function + accelString := gtk_accelerator_name(key, mods) + if accelString == 0 { + return + } + setActionAccelerator(uintptr(app.application), actionName, takeGString(accelString)) +} + +// screen related + +// monitorScale returns the monitor's scale factor. gdk_monitor_get_scale +// (GTK 4.14+) reports fractional scaling; older GTK4 only has the integer +// gdk_monitor_get_scale_factor. This runtime fallback replaces the cgo +// build's compile-time GTK version floor. +func monitorScale(monitor uintptr) float64 { + if gdk_monitor_get_scale != nil { + return gdk_monitor_get_scale(monitor) + } + return float64(gdk_monitor_get_scale_factor(monitor)) +} + +func monitorGeometry(monitor uintptr) gdkRectangle { + var geometry gdkRectangle + gdk_monitor_get_geometry(monitor, uintptr(unsafe.Pointer(&geometry))) + return geometry +} + +// buildScreen assembles a Screen from a monitor's logical geometry and scale. +// GTK4's gdk_monitor_get_geometry returns logical (DIP) coordinates; +// PhysicalBounds needs physical pixel dimensions for proper DPI scaling. +func buildScreen(id string, monitor uintptr, isPrimary bool) *Screen { + geometry := monitorGeometry(monitor) + scaleFactor := monitorScale(monitor) + name := gdk_monitor_get_model(monitor) + + x := int(geometry.x) + y := int(geometry.y) + width := int(geometry.width) + height := int(geometry.height) + + physical := Rect{ + X: int(float64(x) * scaleFactor), + Y: int(float64(y) * scaleFactor), + Height: int(float64(height) * scaleFactor), + Width: int(float64(width) * scaleFactor), + } + + return &Screen{ + ID: id, + Name: name, + IsPrimary: isPrimary, + ScaleFactor: float32(scaleFactor), + X: x, + Y: y, + Size: Size{ + Height: height, + Width: width, + }, + Bounds: Rect{ + X: x, + Y: y, + Height: height, + Width: width, + }, + WorkArea: Rect{ + X: x, + Y: y, + Height: height, + Width: width, + }, + PhysicalBounds: physical, + PhysicalWorkArea: physical, + Rotation: 0.0, + } +} + +func getScreenByIndex(display uintptr, index int) *Screen { + monitors := gdk_display_get_monitors(display) + monitor := g_list_model_get_item(monitors, uint32(index)) + if monitor == 0 { + return nil + } + defer g_object_unref(monitor) + return buildScreen(fmt.Sprintf("%d", index), monitor, index == 0) +} + +func getScreens(app pointer) ([]*Screen, error) { + var screens []*Screen + display := gdk_display_get_default() + monitors := gdk_display_get_monitors(display) + count := g_list_model_get_n_items(monitors) + for i := 0; i < int(count); i++ { + screens = append(screens, getScreenByIndex(display, i)) + } + return screens, nil +} + +// widgets +func (w *linuxWebviewWindow) setEnabled(enabled bool) { + gtk_widget_set_sensitive(uintptr(w.window), gbool(enabled)) +} + +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} + +func widgetSetVisible(widget pointer, hidden bool) { + gtk_widget_set_visible(uintptr(widget), gbool(!hidden)) +} + +func (w *linuxWebviewWindow) close() { + gtk_window_destroy(uintptr(w.window)) + getNativeApplication().unregisterWindow(windowPointer(w.window)) +} + +func (w *linuxWebviewWindow) enableDND() { + enableDNDGo(uintptr(w.webview), uintptr(w.parent.id)) +} + +func (w *linuxWebviewWindow) disableDND() { + // Mirrors the cgo backend: disabling DND after enabling is not + // implemented for GTK4. +} + +func (w *linuxWebviewWindow) execJS(js string) { + InvokeAsync(func() { + script := cString(js) + defer g_free(script) + // WebKitGTK 6.0 uses webkit_web_view_evaluate_javascript + webkit_web_view_evaluate_javascript(w.webKitWebView(), script, len(js), 0, 0, 0, 0, 0) + }) +} + +// Preallocated buffer for drag-over JS calls, matching the cgo backend's +// allocation-free hot path (drop-motion events fire at pointer-move rate). +var dragOverJSBuffer uintptr +var dragOverJSOnce sync.Once + +func (w *linuxWebviewWindow) execJSDragOver(x, y int) { + dragOverJSOnce.Do(func() { + dragOverJSBuffer = g_malloc0(64) + }) + buf := unsafe.Slice((*byte)(unsafe.Pointer(dragOverJSBuffer)), 64) + n := copy(buf, "window._wails.handleDragOver(") + n += writeInt(buf[n:], x) + buf[n] = ',' + n++ + n += writeInt(buf[n:], y) + buf[n] = ')' + n++ + buf[n] = 0 + + webkit_web_view_evaluate_javascript(w.webKitWebView(), dragOverJSBuffer, n, 0, 0, 0, 0, 0) +} + +func writeInt(buf []byte, n int) int { + if n < 0 { + buf[0] = '-' + return 1 + writeInt(buf[1:], -n) + } + if n == 0 { + buf[0] = '0' + return 1 + } + tmp := n + digits := 0 + for tmp > 0 { + digits++ + tmp /= 10 + } + for i := digits - 1; i >= 0; i-- { + buf[i] = byte('0' + n%10) + n /= 10 + } + return digits +} + +func getMousePosition() (int, int, *Screen) { + display := gdk_display_get_default() + if display == 0 { + return 0, 0, nil + } + + monitors := gdk_display_get_monitors(display) + if monitors == 0 { + return 0, 0, nil + } + + n := g_list_model_get_n_items(monitors) + if n == 0 { + return 0, 0, nil + } + + var primaryMonitor uintptr + for i := uint32(0); i < n; i++ { + mon := g_list_model_get_item(monitors, i) + if mon != 0 { + primaryMonitor = mon + break + } + } + + if primaryMonitor == 0 { + return 0, 0, nil + } + defer g_object_unref(primaryMonitor) + + screen := buildScreen("0", primaryMonitor, true) + + centerX := screen.X + screen.Size.Width/2 + centerY := screen.Y + screen.Size.Height/2 + + return centerX, centerY, screen +} + +func (w *linuxWebviewWindow) destroy() { + w.parent.markAsDestroyed() + if w.gtkmenu != nilPointer { + // GTK4: Different menu destruction + w.gtkmenu = nilPointer + } + gtk_window_destroy(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) fullscreen() { + gtk_window_fullscreen(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) getCurrentMonitor() uintptr { + display := gtk_widget_get_display(uintptr(w.window)) + surface := gtk_native_get_surface(uintptr(w.window)) + if surface != 0 { + monitor := gdk_display_get_monitor_at_surface(display, surface) + if monitor != 0 { + return monitor + } + } + return 0 +} + +func (w *linuxWebviewWindow) getScreen() (*Screen, error) { + monitor := w.getCurrentMonitor() + if monitor == 0 { + return nil, fmt.Errorf("no monitor found") + } + screen := buildScreen(fmt.Sprintf("%d", w.id), monitor, false) + return screen, nil +} + +func (w *linuxWebviewWindow) getCurrentMonitorGeometry() (x int, y int, width int, height int, scaleFactor float64) { + monitor := w.getCurrentMonitor() + if monitor == 0 { + return -1, -1, -1, -1, 1 + } + geometry := monitorGeometry(monitor) + scaleFactor = monitorScale(monitor) + return int(geometry.x), int(geometry.y), int(geometry.width), int(geometry.height), scaleFactor +} + +func (w *linuxWebviewWindow) size() (int, int) { + var width, height int32 + gtk_window_get_default_size(uintptr(w.window), + uintptr(unsafe.Pointer(&width)), uintptr(unsafe.Pointer(&height))) + if width <= 0 || height <= 0 { + width = gtk_widget_get_width(uintptr(w.window)) + height = gtk_widget_get_height(uintptr(w.window)) + } + return int(width), int(height) +} + +func (w *linuxWebviewWindow) relativePosition() (int, int) { + x, y := w.position() + monitor := w.getCurrentMonitor() + if monitor == 0 { + return x, y + } + geometry := monitorGeometry(monitor) + return x - int(geometry.x), y - int(geometry.y) +} + +func (w *linuxWebviewWindow) windowHide() { + gtk_widget_set_visible(uintptr(w.window), 0) +} + +func (w *linuxWebviewWindow) isFullscreen() bool { + return gtk_window_is_fullscreen(uintptr(w.window)) != 0 +} + +func (w *linuxWebviewWindow) isFocused() bool { + return gtk_window_is_active(uintptr(w.window)) != 0 +} + +func (w *linuxWebviewWindow) isMaximised() bool { + return gtk_window_is_maximized(uintptr(w.window)) != 0 && !w.isFullscreen() +} + +func (w *linuxWebviewWindow) isMinimised() bool { + surface := gtk_native_get_surface(uintptr(w.window)) + if surface == 0 { + return false + } + state := gdk_toplevel_get_state(surface) + return state&gdkToplevelStateMinimized != 0 +} + +func (w *linuxWebviewWindow) isVisible() bool { + return gtk_widget_is_visible(uintptr(w.window)) != 0 +} + +func (w *linuxWebviewWindow) maximise() { + gtk_window_maximize(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) minimise() { + gtk_window_minimize(uintptr(w.window)) +} + +func windowNew(application pointer, menu pointer, menuStyle LinuxMenuStyle, windowId uint, gpuPolicy WebviewGpuPolicy) (window, webview, vbox pointer) { + window = pointer(gtk_application_window_new(uintptr(application))) + g_object_ref_sink(uintptr(window)) + + attachActionGroupToWidget(uintptr(window)) + + webview = windowNewWebview(windowId, gpuPolicy) + vbox = pointer(gtk_box_new(gtkOrientationVertical, 0)) + gtk_widget_set_name(uintptr(vbox), "webview-box") + + gtk_window_set_child(uintptr(window), uintptr(vbox)) + + if menu != nilPointer { + switch menuStyle { + case LinuxMenuStylePrimaryMenu: + headerBar := createHeaderBarWithMenu(uintptr(menu)) + gtk_window_set_titlebar(uintptr(window), headerBar) + default: + menuBar := createMenuBarFromModel(uintptr(menu)) + gtk_box_prepend(uintptr(vbox), menuBar) + } + } + + gtk_box_append(uintptr(vbox), uintptr(webview)) + gtk_widget_set_vexpand(uintptr(webview), 1) + gtk_widget_set_hexpand(uintptr(webview), 1) + return +} + +func windowNewWebview(parentId uint, gpuPolicy WebviewGpuPolicy) pointer { + manager := webkit_user_content_manager_new() + // WebKitGTK 6.0: register_script_message_handler(manager, name, world_name) + webkit_user_content_manager_register_script_message_handler(manager, "external", 0) + + // Create web view with settings + settings := webkit_settings_new() + // WebKitGTK 6.0 removed webkit_web_view_new_with_user_content_manager; + // user-content-manager is a construct-only property, so build the view + // with g_object_new_with_properties (g_object_new is variadic). + webView := gObjectNewWithObjectProperty(webkit_web_view_get_type(), "user-content-manager", manager) + + saveWebviewToContentManager(manager, webView) + saveWindowID(webView, parentId) + saveWindowID(manager, parentId) + + // GPU policy + // WebKitGTK 6.0: WEBKIT_HARDWARE_ACCELERATION_POLICY_ON_DEMAND was removed + // Only ALWAYS and NEVER are available + switch gpuPolicy { + case WebviewGpuPolicyNever: + webkit_settings_set_hardware_acceleration_policy(settings, webkitHardwareAccelerationPolicyNever) + case WebviewGpuPolicyAlways: + webkit_settings_set_hardware_acceleration_policy(settings, webkitHardwareAccelerationPolicyAlways) + default: + // Default to ALWAYS (was ON_DEMAND in older WebKitGTK) + webkit_settings_set_hardware_acceleration_policy(settings, webkitHardwareAccelerationPolicyAlways) + } + + webkit_web_view_set_settings(webView, settings) + + // Register URI scheme handler + registerURIScheme.Do(func() { + webContext := webkit_web_view_get_context(webView) + webkit_web_context_register_uri_scheme(webContext, "wails", onProcessRequestPtr, 0, 0) + }) + + // Start the periodic signal-handler fix now that a WebView exists and JSC + // can actually initialise. Anchoring to first webview creation (not appNew) + // ensures the 5s window covers the JSC lazy-init race window. + fixSignalHandlers.Do(func() { + installSignalHandlers() + scheduleSignalHandlerFix() + }) + + return pointer(webView) +} + +func (w *linuxWebviewWindow) webKitWebView() uintptr { + // The webview widget IS the WebKitWebView instance (WEBKIT_WEB_VIEW is + // just a checked cast in C). + return uintptr(w.webview) +} + +func (w *linuxWebviewWindow) present() { + gtk_window_present(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) setTitle(title string) { + if !w.parent.options.Frameless { + gtk_window_set_title(uintptr(w.window), title) + } +} + +func (w *linuxWebviewWindow) setSize(width, height int) { + gtk_window_set_default_size(uintptr(w.window), int32(width), int32(height)) +} + +func (w *linuxWebviewWindow) setDefaultSize(width int, height int) { + gtk_window_set_default_size(uintptr(w.window), int32(width), int32(height)) +} + +func windowSetGeometryHints(window pointer, minWidth, minHeight, maxWidth, maxHeight int) { + if minWidth > 0 && minHeight > 0 { + gtk_widget_set_size_request(uintptr(window), int32(minWidth), int32(minHeight)) + } + if maxWidth > 0 || maxHeight > 0 { + windowSetMaxSize(uintptr(window), maxWidth, maxHeight) + } +} + +func (w *linuxWebviewWindow) setResizable(resizable bool) { + gtk_window_set_resizable(uintptr(w.window), gbool(resizable)) + w.execJS(fmt.Sprintf("if(window._wails&&window._wails.setResizable)window._wails.setResizable(%v);", resizable)) +} + +func (w *linuxWebviewWindow) move(x, y int) { + // The GDK_IS_X11_DISPLAY-equivalent check inside handles X11 vs Wayland + // correctly, including XWayland and GDK_BACKEND=x11 scenarios. + windowMoveX11(uintptr(w.window), x, y) +} + +func (w *linuxWebviewWindow) position() (int, int) { + // Returns 0,0 on non-X11 displays, matching the cgo backend. + return windowGetPositionX11(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) unfullscreen() { + gtk_window_unfullscreen(uintptr(w.window)) + w.unmaximise() +} + +func (w *linuxWebviewWindow) unmaximise() { + gtk_window_unmaximize(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) windowShow() { + if w.window == nilPointer { + return + } + gtk_window_present(uintptr(w.window)) + // Re-apply always-on-top state now that the surface exists. + windowApplyPendingAlwaysOnTop(uintptr(w.window)) +} + +func (w *linuxWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) { + // X11 only: uses _NET_WM_STATE_ABOVE. No-op on Wayland (no standard protocol). + windowSetAlwaysOnTop(uintptr(w.window), alwaysOnTop) +} + +func (w *linuxWebviewWindow) setBorderless(borderless bool) { + gtk_window_set_decorated(uintptr(w.window), gbool(!borderless)) +} + +func (w *linuxWebviewWindow) setFrameless(frameless bool) { + gtk_window_set_decorated(uintptr(w.window), gbool(!frameless)) + w.execJS(fmt.Sprintf("if(window._wails&&window._wails.flags)window._wails.flags.frameless=%v;", frameless)) +} + +func (w *linuxWebviewWindow) setTransparent() { + // GTK4: Transparency via CSS - different from GTK3 +} + +func (w *linuxWebviewWindow) setBackgroundColour(colour RGBA) { + rgba := gdkRGBA{ + red: float32(colour.Red) / 255.0, + green: float32(colour.Green) / 255.0, + blue: float32(colour.Blue) / 255.0, + alpha: float32(colour.Alpha) / 255.0, + } + webkit_web_view_set_background_color(w.webKitWebView(), uintptr(unsafe.Pointer(&rgba))) +} + +func (w *linuxWebviewWindow) setIcon(icon pointer) { + // GTK4 removed gtk_window_set_icon. Window icons are set via the + // application's .desktop file at the desktop-integration level. +} + +func (w *linuxWebviewWindow) startDrag() error { + beginWindowDrag(uintptr(w.window), + int32(w.drag.MouseButton), + float64(w.drag.XRoot), + float64(w.drag.YRoot), + w.drag.DragTime) + return nil +} + +// gdkSurfaceEdgeForBorder maps the border strings sent by the Wails runtime +// (as injected by drag.ts — "n-resize", "ne-resize", etc.) to the +// corresponding GdkSurfaceEdge value expected by gdk_toplevel_begin_resize. +// GdkSurfaceEdge values (gdk/gdkenums.h): NORTH_WEST=0, NORTH=1, NORTH_EAST=2, +// WEST=3, EAST=4, SOUTH_WEST=5, SOUTH=6, SOUTH_EAST=7. +var gdkSurfaceEdgeForBorder = map[string]int32{ + "nw-resize": 0, + "n-resize": 1, + "ne-resize": 2, + "w-resize": 3, + "e-resize": 4, + "sw-resize": 5, + "s-resize": 6, + "se-resize": 7, +} + +func (w *linuxWebviewWindow) startResize(border string) error { + edge, ok := gdkSurfaceEdgeForBorder[border] + if !ok { + return fmt.Errorf("unknown resize border: %q", border) + } + // Drag state (mouse button, root coords, timestamp) was captured by + // the click gesture in the GTK4 controller and stored on w.drag. + beginWindowResize(uintptr(w.window), edge, + int32(w.drag.MouseButton), + float64(w.drag.XRoot), + float64(w.drag.YRoot), + w.drag.DragTime) + return nil +} + +func (w *linuxWebviewWindow) getZoom() float64 { + return webkit_web_view_get_zoom_level(w.webKitWebView()) +} + +func (w *linuxWebviewWindow) setZoom(zoom float64) { + if zoom < 1 { + zoom = 1 + } + webkit_web_view_set_zoom_level(w.webKitWebView(), zoom) +} + +func (w *linuxWebviewWindow) zoomIn() { + w.setZoom(w.getZoom() * 1.10) +} + +func (w *linuxWebviewWindow) zoomOut() { + w.setZoom(w.getZoom() / 1.10) +} + +func (w *linuxWebviewWindow) zoomReset() { + w.setZoom(1.0) +} + +func (w *linuxWebviewWindow) reload() { + webkit_web_view_load_uri(w.webKitWebView(), "wails://") +} + +func (w *linuxWebviewWindow) setURL(uri string) { + webkit_web_view_load_uri(w.webKitWebView(), uri) +} + +func (w *linuxWebviewWindow) setHTML(html string) { + webkit_web_view_load_alternate_html(w.webKitWebView(), html, "wails://", "") +} + +func (w *linuxWebviewWindow) flash(_ bool) {} + +func (w *linuxWebviewWindow) setOpacity(opacity float64) { + gtk_widget_set_opacity(uintptr(w.window), opacity) +} + +func (w *linuxWebviewWindow) ignoreMouse(ignore bool) { + // GTK4: Input handling is different +} + +func (w *linuxWebviewWindow) copy() { + w.execJS("document.execCommand('copy')") +} + +func (w *linuxWebviewWindow) cut() { + w.execJS("document.execCommand('cut')") +} + +func (w *linuxWebviewWindow) paste() { + w.execJS("document.execCommand('paste')") +} + +func (w *linuxWebviewWindow) delete() { + w.execJS("document.execCommand('delete')") +} + +func (w *linuxWebviewWindow) selectAll() { + w.execJS("document.execCommand('selectAll')") +} + +func (w *linuxWebviewWindow) undo() { + w.execJS("document.execCommand('undo')") +} + +func (w *linuxWebviewWindow) redo() { + w.execJS("document.execCommand('redo')") +} + +func (w *linuxWebviewWindow) setupSignalHandlers(emit func(e events.WindowEventType)) { + winID := uintptr(w.parent.ID()) + + setupWindowEventControllers(uintptr(w.window), uintptr(w.webview), winID) + + signalConnect(uintptr(w.webview), "load-changed", handleLoadChangedPtr, winID) + signalConnect(uintptr(w.webview), "permission-request", handlePermissionRequestPtr, winID) + + contentManager := webkit_web_view_get_user_content_manager(w.webKitWebView()) + signalConnect(contentManager, "script-message-received::external", sendMessageToBackendPtr, 0) +} + +// onProcessRequestGo forwards a WebKitURISchemeRequest to the asset server +// (called from the registered URI scheme trampoline on the main thread). +func onProcessRequestGo(request uintptr) { + webView := webkit_uri_scheme_request_get_web_view(request) + windowId := windowIDFromObject(webView) + webviewRequests <- &webViewAssetRequest{ + Request: webview.NewRequest(unsafe.Pointer(request)), + windowId: windowId, + windowName: func() string { + if window, ok := globalApplication.Window.GetByID(windowId); ok { + return window.Name() + } + return "" + }(), + } +} + +// ============================================================================ +// GTK4 Dialog System +// ============================================================================ + +// Dialog request tracking +var ( + dialogRequestCounter uint32 + dialogRequestMutex sync.Mutex + fileDialogCallbacks = make(map[uint]chan string) + alertDialogCallbacks = make(map[uint]chan int) +) + +func nextDialogRequestID() uint { + dialogRequestMutex.Lock() + defer dialogRequestMutex.Unlock() + dialogRequestCounter++ + return uint(dialogRequestCounter) +} + +// fileDialogCallback delivers the chosen paths to the waiting dialog channel. +// +// BUGS_FOUND #6: the cgo backend sends each path inline on the GTK main +// thread into a channel with a fixed buffer of 100 — selecting more files +// than that deadlocks the main loop if the consumer isn't already draining. +// The results are handed off to a goroutine here, so the main thread never +// blocks regardless of selection size or consumer behaviour. +func fileDialogCallback(requestID uint, files []string, cancelled bool) { + dialogRequestMutex.Lock() + ch, ok := fileDialogCallbacks[requestID] + if ok { + delete(fileDialogCallbacks, requestID) + } + dialogRequestMutex.Unlock() + + if !ok { + return + } + + if cancelled { + close(ch) + return + } + + go func() { + defer handlePanic() + for _, file := range files { + ch <- file + } + close(ch) + }() +} + +func alertDialogCallback(requestID uint, buttonIndex int) { + dialogRequestMutex.Lock() + ch, ok := alertDialogCallbacks[requestID] + if ok { + delete(alertDialogCallbacks, requestID) + } + dialogRequestMutex.Unlock() + + if !ok { + return + } + + ch <- buttonIndex + close(ch) +} + +func runChooserDialog(window pointer, allowMultiple, createFolders, showHidden bool, currentFolder, title string, action int, acceptLabel string, filters []FileFilter) (chan string, error) { + requestID := nextDialogRequestID() + resultChan := make(chan string, 100) + + dialogRequestMutex.Lock() + fileDialogCallbacks[requestID] = resultChan + dialogRequestMutex.Unlock() + + InvokeAsync(func() { + dialog := gtk_file_dialog_new() + gtk_file_dialog_set_title(dialog, title) + + // Create filter list if we have filters + if len(filters) > 0 { + filterStore := g_list_store_new(gtk_file_filter_get_type()) + defer g_object_unref(filterStore) + + for _, filter := range filters { + addFileFilter(dialog, filterStore, filter.DisplayName, filter.Pattern) + } + gtk_file_dialog_set_filters(dialog, filterStore) + } + + if currentFolder != "" { + file := g_file_new_for_path(currentFolder) + gtk_file_dialog_set_initial_folder(dialog, file) + g_object_unref(file) + } + + if acceptLabel != "" { + gtk_file_dialog_set_accept_label(dialog, acceptLabel) + } + + isFolder := action == 2 + isSave := action == 1 + + if isSave { + showSaveFileDialog(uintptr(window), dialog, requestID) + } else { + showOpenFileDialog(uintptr(window), dialog, requestID, allowMultiple, isFolder) + } + }) + + return resultChan, nil +} + +func runOpenFileDialog(dialog *OpenFileDialogStruct) (chan string, error) { + var action int + + if dialog.canChooseDirectories { + action = 2 // GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER + } else { + action = 0 // GTK_FILE_CHOOSER_ACTION_OPEN + } + + window := nilPointer + if dialog.window != nil { + nativeWindow := dialog.window.NativeWindow() + if nativeWindow != nil { + window = pointer(uintptr(nativeWindow)) + } + } + + buttonText := dialog.buttonText + if buttonText == "" { + buttonText = "_Open" + } + + return runChooserDialog( + window, + dialog.allowsMultipleSelection, + false, // createFolders not applicable for open + dialog.showHiddenFiles, + dialog.directory, + dialog.title, + action, + buttonText, + dialog.filters, + ) +} + +func runSaveFileDialog(dialog *SaveFileDialogStruct) (chan string, error) { + window := nilPointer + if dialog.window != nil { + nativeWindow := dialog.window.NativeWindow() + if nativeWindow != nil { + window = pointer(uintptr(nativeWindow)) + } + } + + buttonText := dialog.buttonText + if buttonText == "" { + buttonText = "_Save" + } + + return runChooserDialog( + window, + false, + dialog.canCreateDirectories, + dialog.showHiddenFiles, + dialog.directory, + dialog.title, + 1, // GTK_FILE_CHOOSER_ACTION_SAVE + buttonText, + dialog.filters, + ) +} + +func dialogTypeToIconName(dialogType DialogType) string { + switch dialogType { + case InfoDialogType: + return "dialog-information-symbolic" + case WarningDialogType: + return "dialog-warning-symbolic" + case ErrorDialogType: + return "dialog-error-symbolic" + case QuestionDialogType: + return "dialog-question-symbolic" + default: + return "" + } +} + +func runQuestionDialog(parent pointer, options *MessageDialog) int { + requestID := nextDialogRequestID() + resultChan := make(chan int, 1) + + dialogRequestMutex.Lock() + alertDialogCallbacks[requestID] = resultChan + dialogRequestMutex.Unlock() + + InvokeAsync(func() { + var iconName string + var iconData []byte + if len(options.Icon) > 0 { + iconData = options.Icon + } else { + iconName = dialogTypeToIconName(options.DialogType) + } + + buttons := options.Buttons + if len(buttons) == 0 { + buttons = []*Button{{Label: "OK", IsDefault: true}} + } + + buttonLabels := make([]string, len(buttons)) + for i, btn := range buttons { + buttonLabels[i] = btn.Label + } + + defaultButton := -1 + cancelButton := -1 + destructiveButton := -1 + for i, btn := range buttons { + if btn.IsDefault { + defaultButton = i + } + if btn.IsCancel { + cancelButton = i + } + } + + if options.DialogType == ErrorDialogType || options.DialogType == WarningDialogType { + if defaultButton >= 0 && !buttons[defaultButton].IsCancel { + destructiveButton = defaultButton + defaultButton = -1 + } + } + + showMessageDialog(uintptr(parent), options.Title, options.Message, + iconName, iconData, buttonLabels, + defaultButton, cancelButton, destructiveButton, requestID) + }) + + // Wait for result + result := <-resultChan + return result +} + +func getPrimaryScreen() (*Screen, error) { + display := gdk_display_get_default() + monitors := gdk_display_get_monitors(display) + if monitors == 0 { + return nil, fmt.Errorf("no monitors found") + } + count := g_list_model_get_n_items(monitors) + if count == 0 { + return nil, fmt.Errorf("no monitors found") + } + monitor := g_list_model_get_item(monitors, 0) + if monitor == 0 { + return nil, fmt.Errorf("failed to get primary monitor") + } + defer g_object_unref(monitor) + + return buildScreen("0", monitor, true), nil +} + +func openDevTools(wv pointer) { + inspector := webkit_web_view_get_inspector(uintptr(wv)) + webkit_web_inspector_show(inspector) +} + +func enableDevTools(wv pointer) { + settings := webkit_web_view_get_settings(uintptr(wv)) + enabled := webkit_settings_get_enable_developer_extras(settings) + if enabled == 0 { + webkit_settings_set_enable_developer_extras(settings, 1) + } else { + webkit_settings_set_enable_developer_extras(settings, 0) + } +} + +// splitAndTrim splits s on sep and trims surrounding whitespace from each part. +func splitAndTrim(s, sep string) []string { + var out []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i:i+1] == sep { + part := s[start:i] + // trim spaces/tabs + for len(part) > 0 && (part[0] == ' ' || part[0] == '\t') { + part = part[1:] + } + for len(part) > 0 && (part[len(part)-1] == ' ' || part[len(part)-1] == '\t') { + part = part[:len(part)-1] + } + out = append(out, part) + start = i + 1 + } + } + return out +} diff --git a/v3/pkg/application/linux_purego_callbacks.go b/v3/pkg/application/linux_purego_callbacks.go new file mode 100644 index 00000000000..bde8d00e967 --- /dev/null +++ b/v3/pkg/application/linux_purego_callbacks.go @@ -0,0 +1,1358 @@ +//go:build linux && purego && !gtk3 && !android && !server + +package application + +// Pure-Go port of linux_cgo.c: the GTK signal trampolines, main-thread +// dispatch, GAction-based menu machinery, GTK4 dialogs, drag-and-drop and the +// X11 window helpers. Every C callback becomes a package-level +// purego.NewCallback — a fixed, small set (purego's callback slots are capped +// process-wide and never freed, so nothing here creates callbacks per +// call/window/item). + +import ( + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// ---------------------------------------------------------------------------- +// Constants (values from the GTK4/GDK/GLib/WebKitGTK-6.0 headers; there is no +// compile step to import them from, so they are transcribed here) +// ---------------------------------------------------------------------------- + +const ( + gSourceRemove = 0 // G_SOURCE_REMOVE (FALSE) + gSourceContinue = 1 // G_SOURCE_CONTINUE (TRUE) + gPriorityDefault = 0 + + gApplicationDefaultFlags = 0 // G_APPLICATION_DEFAULT_FLAGS + + gtkOrientationHorizontal = 0 + gtkOrientationVertical = 1 + + gtkAlignCenter = 3 // GtkAlign: FILL=0 START=1 END=2 CENTER=3 + + gtkPosBottom = 3 // GtkPositionType: LEFT=0 RIGHT=1 TOP=2 BOTTOM=3 + + gtkPhaseCapture = 1 // GtkPropagationPhase: NONE=0 CAPTURE=1 BUBBLE=2 TARGET=3 + + gdkActionCopy = 1 << 0 // GdkDragAction + + gdkCurrentTime = 0 // GDK_CURRENT_TIME + + // GdkToplevelState (gdk/gdktoplevel.h) + gdkToplevelStateMinimized = 1 << 0 + + // GdkModifierType (gdk/gdkenums.h) + gdkShiftMask = 1 << 0 + gdkControlMask = 1 << 2 + gdkAltMask = 1 << 3 + gdkSuperMask = 1 << 26 + + gdkKeyEscape = 0xff1b + + // WebKitLoadEvent (webkit/WebKitWebView.h) + webkitLoadStarted = 0 + webkitLoadRedirected = 1 + webkitLoadCommitted = 2 + webkitLoadFinished = 3 + + // WebKitHardwareAccelerationPolicy (WebKitGTK 6.0: ON_DEMAND was removed, + // leaving ALWAYS=0, NEVER=1) + webkitHardwareAccelerationPolicyAlways = 0 + webkitHardwareAccelerationPolicyNever = 1 +) + +// ---------------------------------------------------------------------------- +// Main-thread dispatch (g_idle_add onto the GTK main loop) +// ---------------------------------------------------------------------------- + +var dispatchCallbackPtr = purego.NewCallback(func(data uintptr) uintptr { + executeOnMainThread(uint(data)) + return gSourceRemove +}) + +func dispatchOnMainThread(id uint) { + g_idle_add(dispatchCallbackPtr, uintptr(id)) +} + +// ---------------------------------------------------------------------------- +// Signal handling (SA_ONSTACK fix, port of install_signal_handlers) +// ---------------------------------------------------------------------------- + +// glibc/musl struct sigaction on linux amd64/arm64: +// +// 0 sa_handler (8) +// 8 sa_mask (128) +// 136 sa_flags (4) +// 144 sa_restorer (8) +const ( + sigactionSize = 152 + sigactionFlagsOff = 136 + saOnStack = 0x08000000 +) + +func fixSignal(sig syscall.Signal) { + if libc_sigaction == nil { + return + } + var st [sigactionSize + 8]byte + p := uintptr(unsafe.Pointer(&st[0])) + if libc_sigaction(int32(sig), 0, p) < 0 { + return + } + flags := (*int32)(unsafe.Pointer(&st[sigactionFlagsOff])) + *flags |= saOnStack + libc_sigaction(int32(sig), p, 0) +} + +// installSignalHandlers re-applies SA_ONSTACK to the signals Go cares about. +// GTK/WebKit install their own handlers without SA_ONSTACK; without this fix +// they run on goroutine stacks and crash the Go runtime. +// +// NOTE: SIGUSR1 is deliberately NOT fixed. WebKit's JavaScriptCore uses +// SIGUSR1 to suspend/resume threads for conservative GC stack scanning; once +// JSC owns that signal, forcing SA_ONSTACK onto its handler breaks GC thread +// synchronisation and freezes WebKit during idle collection. See issue #5527. +func installSignalHandlers() { + for _, sig := range []syscall.Signal{ + syscall.SIGCHLD, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, + syscall.SIGABRT, syscall.SIGFPE, syscall.SIGTERM, syscall.SIGBUS, + syscall.SIGSEGV, syscall.SIGXCPU, syscall.SIGXFSZ, + } { + fixSignal(sig) + } +} + +// WebKit's JSC lazily installs signal handlers without SA_ONSTACK when +// JavaScript first executes. This timer re-applies the fix every 50ms for the +// first 5 seconds, covering the JSC initialization window. +var signalFixRemaining int32 + +var signalFixTimeoutPtr = purego.NewCallback(func(data uintptr) uintptr { + installSignalHandlers() + signalFixRemaining-- + if signalFixRemaining <= 0 { + return gSourceRemove + } + return gSourceContinue +}) + +func scheduleSignalHandlerFix() { + signalFixRemaining = 100 + g_timeout_add_full(gPriorityDefault, 50, signalFixTimeoutPtr, 0, 0) +} + +// ---------------------------------------------------------------------------- +// Object data helpers (port of save_window_id & co) +// ---------------------------------------------------------------------------- + +func saveWindowID(object uintptr, id uint) { + g_object_set_data(object, "windowid", uintptr(id)) +} + +func windowIDFromObject(object uintptr) uint { + return uint(g_object_get_data(object, "windowid")) +} + +func saveWebviewToContentManager(contentManager, webview uintptr) { + g_object_set_data(contentManager, "webview", webview) +} + +func getWebviewFromContentManager(contentManager uintptr) uintptr { + return g_object_get_data(contentManager, "webview") +} + +// ---------------------------------------------------------------------------- +// Application activate +// ---------------------------------------------------------------------------- + +var activateLinuxPtr = purego.NewCallback(func(app, data uintptr) uintptr { + nativeApp := getNativeApplication() + nativeApp.markActivated() + processApplicationEvent(uint(events.Linux.ApplicationStartup), nilPointer) + return 0 +}) + +func processApplicationEvent(eventID uint, _ pointer) { + event := newApplicationEvent(events.ApplicationEventType(eventID)) + + switch event.Id { + case uint(events.Linux.SystemThemeChanged): + isDark := globalApplication.Env.IsDarkMode() + event.Context().setIsDarkMode(isDark) + } + applicationEvents <- event +} + +func processWindowEvent(windowID uint, eventID uint) { + windowEvents <- &windowEvent{ + WindowID: windowID, + EventID: eventID, + } +} + +// ---------------------------------------------------------------------------- +// Window / webview signal trampolines +// (port of setupWindowEventControllers and the //export handlers) +// ---------------------------------------------------------------------------- + +var handleCloseRequestPtr = purego.NewCallback(func(window, data uintptr) uintptr { + processWindowEvent(uint(data), uint(events.Linux.WindowDeleteEvent)) + return 1 // stop the default handler destroying the window +}) + +var handleNotifyStatePtr = purego.NewCallback(func(object, pspec, data uintptr) uintptr { + windowId := uint(data) + window, ok := globalApplication.Window.GetByID(windowId) + if !ok || window == nil { + return 0 + } + lw := getLinuxWebviewWindow(window) + if lw == nil { + return 0 + } + if lw.isMaximised() { + processWindowEvent(windowId, uint(events.Linux.WindowDidResize)) + } + if lw.isFullscreen() { + processWindowEvent(windowId, uint(events.Linux.WindowDidResize)) + } + return 0 +}) + +var handleFocusEnterPtr = purego.NewCallback(func(controller, data uintptr) uintptr { + processWindowEvent(uint(data), uint(events.Linux.WindowFocusIn)) + return 0 +}) + +var handleFocusLeavePtr = purego.NewCallback(func(controller, data uintptr) uintptr { + processWindowEvent(uint(data), uint(events.Linux.WindowFocusOut)) + return 0 +}) + +var handleLoadChangedPtr = purego.NewCallback(func(wv uintptr, event int32, data uintptr) uintptr { + switch event { + case webkitLoadStarted: + processWindowEvent(uint(data), uint(events.Linux.WindowLoadStarted)) + case webkitLoadRedirected: + processWindowEvent(uint(data), uint(events.Linux.WindowLoadRedirected)) + case webkitLoadCommitted: + processWindowEvent(uint(data), uint(events.Linux.WindowLoadCommitted)) + case webkitLoadFinished: + // JSC is guaranteed to have initialised by page-load completion, so + // re-apply SA_ONSTACK now to cover any handlers it installed during load. + installSignalHandlers() + processWindowEvent(uint(data), uint(events.Linux.WindowLoadFinished)) + } + return 0 +}) + +var handlePermissionRequestPtr = purego.NewCallback(func(wv, request, data uintptr) uintptr { + // WebKitGTK denies any permission request nobody handles, so without this + // getUserMedia always fails with NotAllowedError. Honour the window's + // Permissions for camera/microphone; leave every other request to WebKit's + // default handling (deny). + if !gTypeInstanceIsA(request, webkit_user_media_permission_request_get_type()) { + return 0 + } + needAudio := webkit_user_media_permission_is_for_audio_device(request) != 0 + needVideo := webkit_user_media_permission_is_for_video_device(request) != 0 + if allowMediaCapture(uint(data), needAudio, needVideo) { + webkit_permission_request_allow(request) + } else { + webkit_permission_request_deny(request) + } + return 1 +}) + +var handleButtonPressedPtr = purego.NewCallback(func(gesture uintptr, nPress int32, x, y float64, data uintptr) uintptr { + windowId := uint(data) + window, ok := globalApplication.Window.GetByID(windowId) + if !ok || window == nil { + return 0 + } + lw := getLinuxWebviewWindow(window) + if lw == nil { + return 0 + } + button := gtk_gesture_single_get_current_button(gesture) + lw.drag.MouseButton = uint(button) + lw.drag.XRoot = int(x) + lw.drag.YRoot = int(y) + lw.drag.DragTime = uint32(gdkCurrentTime) + return 0 +}) + +var handleButtonReleasedPtr = purego.NewCallback(func(gesture uintptr, nPress int32, x, y float64, data uintptr) uintptr { + windowId := uint(data) + window, ok := globalApplication.Window.GetByID(windowId) + if !ok || window == nil { + return 0 + } + lw := getLinuxWebviewWindow(window) + if lw == nil { + return 0 + } + button := gtk_gesture_single_get_current_button(gesture) + lw.endDrag(uint(button), int(x), int(y)) + return 0 +}) + +var handleKeyPressedPtr = purego.NewCallback(func(controller uintptr, keyval, keycode uint32, state uint32, data uintptr) uintptr { + windowID := uint(data) + + modifiers := uint(state) + var acc accelerator + + if modifiers&gdkShiftMask != 0 { + acc.Modifiers = append(acc.Modifiers, ShiftKey) + } + if modifiers&gdkControlMask != 0 { + acc.Modifiers = append(acc.Modifiers, ControlKey) + } + if modifiers&gdkAltMask != 0 { + acc.Modifiers = append(acc.Modifiers, OptionOrAltKey) + } + if modifiers&gdkSuperMask != 0 { + acc.Modifiers = append(acc.Modifiers, SuperKey) + } + + keyString, ok := VirtualKeyCodes[uint(keyval)] + if !ok { + return 0 + } + acc.Key = keyString + + windowKeyEvents <- &windowKeyEvent{ + windowId: windowID, + acceleratorString: acc.String(), + } + + return 0 +}) + +// setupWindowEventControllers wires the GTK4-style event controllers for a +// window and its webview (port of the C function of the same name). +func setupWindowEventControllers(window, webview uintptr, winID uintptr) { + // Close request (replaces delete-event) + signalConnect(window, "close-request", handleCloseRequestPtr, winID) + + // Window state changes (maximize, fullscreen, etc) + signalConnect(window, "notify::maximized", handleNotifyStatePtr, winID) + signalConnect(window, "notify::fullscreened", handleNotifyStatePtr, winID) + + // Focus controller for window + focusController := gtk_event_controller_focus_new() + gtk_widget_add_controller(window, focusController) + signalConnect(focusController, "enter", handleFocusEnterPtr, winID) + signalConnect(focusController, "leave", handleFocusLeavePtr, winID) + + // Click gesture for webview (button press/release) + clickGesture := gtk_gesture_click_new() + gtk_gesture_single_set_button(clickGesture, 0) // listen to all buttons + gtk_widget_add_controller(webview, clickGesture) + signalConnect(clickGesture, "pressed", handleButtonPressedPtr, winID) + signalConnect(clickGesture, "released", handleButtonReleasedPtr, winID) + + // Key controller for webview + keyController := gtk_event_controller_key_new() + gtk_widget_add_controller(webview, keyController) + signalConnect(keyController, "key-pressed", handleKeyPressedPtr, winID) +} + +// ---------------------------------------------------------------------------- +// Asset scheme + script-message bridge +// ---------------------------------------------------------------------------- + +var onProcessRequestPtr = purego.NewCallback(func(request, data uintptr) uintptr { + onProcessRequestGo(request) + return 0 +}) + +var sendMessageToBackendPtr = purego.NewCallback(func(contentManager, value, data uintptr) uintptr { + // Get the windowID from the contentManager + thisWindowID := windowIDFromObject(contentManager) + + webView := getWebviewFromContentManager(contentManager) + var origin string + if webView != 0 { + currentURI := webkit_web_view_get_uri(webView) + if currentURI != 0 { + origin = goString(currentURI) + } + } + + // WebKitGTK 6.0: the JSCValue is passed directly + msg := takeGString(jsc_value_to_string(value)) + windowMessageBuffer <- &windowMessage{ + windowId: thisWindowID, + message: msg, + originInfo: &OriginInfo{ + Origin: origin, + }, + } + return 0 +}) + +// ---------------------------------------------------------------------------- +// Window drag / resize (GdkToplevel) +// ---------------------------------------------------------------------------- + +func toplevelForWindow(window uintptr) uintptr { + native := gtk_widget_get_native(window) + if native == 0 { + return 0 + } + // A GtkWindow's native surface is a GdkToplevel; gtk_native_get_surface + // returns 0 before the window is realized. + return gtk_native_get_surface(native) +} + +func beginWindowDrag(window uintptr, button int32, x, y float64, timestamp uint32) { + surface := toplevelForWindow(window) + if surface == 0 { + return + } + var device uintptr + display := gdk_surface_get_display(surface) + if seat := gdk_display_get_default_seat(display); seat != 0 { + device = gdk_seat_get_pointer(seat) + } + gdk_toplevel_begin_move(surface, device, button, x, y, timestamp) +} + +func beginWindowResize(window uintptr, edge int32, button int32, x, y float64, timestamp uint32) { + surface := toplevelForWindow(window) + if surface == 0 { + return + } + var device uintptr + display := gdk_surface_get_display(surface) + if seat := gdk_display_get_default_seat(display); seat != 0 { + device = gdk_seat_get_pointer(seat) + } + gdk_toplevel_begin_resize(surface, edge, device, button, x, y, timestamp) +} + +// ---------------------------------------------------------------------------- +// Drag and drop (GtkDropTarget + GtkDropControllerMotion) +// ---------------------------------------------------------------------------- + +var onDropAcceptPtr = purego.NewCallback(func(target, drop, data uintptr) uintptr { + formats := gdk_drop_get_formats(drop) + if gdk_content_formats_contain_gtype(formats, gdk_file_list_get_type()) != 0 { + return 1 + } + return 0 +}) + +var onDropEnterPtr = purego.NewCallback(func(target uintptr, x, y float64, data uintptr) uintptr { + onDropEnterGo(uint(data)) + return gdkActionCopy +}) + +var onDropLeavePtr = purego.NewCallback(func(target, data uintptr) uintptr { + onDropLeaveGo(uint(data)) + return 0 +}) + +var onDropMotionPtr = purego.NewCallback(func(target uintptr, x, y float64, data uintptr) uintptr { + onDropMotionGo(int(x), int(y), uint(data)) + return gdkActionCopy +}) + +var onDropPtr = purego.NewCallback(func(target, value uintptr, x, y float64, data uintptr) uintptr { + return uintptr(handleDrop(value, int(x), int(y), uint(data))) +}) + +var onMotionEnterPtr = purego.NewCallback(func(ctrl uintptr, x, y float64, data uintptr) uintptr { + onDropEnterGo(uint(data)) + return 0 +}) + +var onMotionLeavePtr = purego.NewCallback(func(ctrl, data uintptr) uintptr { + onDropLeaveGo(uint(data)) + return 0 +}) + +var onMotionMotionPtr = purego.NewCallback(func(ctrl uintptr, x, y float64, data uintptr) uintptr { + onDropMotionGo(int(x), int(y), uint(data)) + return 0 +}) + +func onDropEnterGo(windowId uint) { + targetWindow, ok := globalApplication.Window.GetByID(windowId) + if !ok || targetWindow == nil { + return + } + if w, ok := targetWindow.(*WebviewWindow); ok { + w.HandleDragEnter() + } +} + +func onDropLeaveGo(windowId uint) { + targetWindow, ok := globalApplication.Window.GetByID(windowId) + if !ok || targetWindow == nil { + return + } + if w, ok := targetWindow.(*WebviewWindow); ok { + w.HandleDragLeave() + } +} + +func onDropMotionGo(x, y int, windowId uint) { + targetWindow, ok := globalApplication.Window.GetByID(windowId) + if !ok || targetWindow == nil { + return + } + if w, ok := targetWindow.(*WebviewWindow); ok { + w.HandleDragOver(x, y) + } +} + +// handleDrop extracts the GFile list out of the dropped GValue and forwards +// the paths (port of on_drop). Returns 1 when the drop was handled. +func handleDrop(value uintptr, x, y int, windowId uint) int32 { + if g_type_check_value_holds(value, gdk_file_list_get_type()) == 0 { + return 0 + } + fileList := g_value_get_boxed(value) + if fileList == 0 { + return 0 + } + count := g_slist_length(fileList) + if count == 0 { + return 0 + } + + targetWindow, ok := globalApplication.Window.GetByID(windowId) + if !ok || targetWindow == nil { + return 0 + } + + var filenames []string + for l := (*GSList)(unsafe.Pointer(fileList)); l != nil; l = l.next { + if path := g_file_get_path(uintptr(l.data)); path != 0 { + filenames = append(filenames, takeGString(path)) + } + } + + targetWindow.InitiateFrontendDropProcessing(filenames, x, y) + return 1 +} + +func enableDNDGo(widget uintptr, winID uintptr) { + motionCtrl := gtk_drop_controller_motion_new() + gtk_event_controller_set_propagation_phase(motionCtrl, gtkPhaseCapture) + signalConnect(motionCtrl, "enter", onMotionEnterPtr, winID) + signalConnect(motionCtrl, "leave", onMotionLeavePtr, winID) + signalConnect(motionCtrl, "motion", onMotionMotionPtr, winID) + gtk_widget_add_controller(widget, motionCtrl) + + target := gtk_drop_target_new(gdk_file_list_get_type(), gdkActionCopy) + gtk_event_controller_set_propagation_phase(target, gtkPhaseCapture) + signalConnect(target, "accept", onDropAcceptPtr, winID) + signalConnect(target, "enter", onDropEnterPtr, winID) + signalConnect(target, "leave", onDropLeavePtr, winID) + signalConnect(target, "motion", onDropMotionPtr, winID) + signalConnect(target, "drop", onDropPtr, winID) + gtk_widget_add_controller(widget, target) +} + +// ---------------------------------------------------------------------------- +// Menus: GSimpleActionGroup + GAction activation +// ---------------------------------------------------------------------------- + +var ( + appActionGroup uintptr + appActionGroupOnce sync.Once + appMenuModel uintptr +) + +func initAppActionGroup() { + appActionGroupOnce.Do(func() { + appActionGroup = g_simple_action_group_new() + }) +} + +// onActionActivated handles plain and checkbox menu actions. The menu item id +// is attached to the GSimpleAction as object data at creation time (the C +// implementation used a heap-allocated MenuItemData for the same purpose). +var onActionActivatedPtr = purego.NewCallback(func(action, parameter, data uintptr) uintptr { + menuActionActivated(uint(data)) + return 0 +}) + +// onRadioActionActivated switches the stateful string action to the activated +// target and fires the menu item encoded in the target string. +var onRadioActionActivatedPtr = purego.NewCallback(func(action, parameter, data uintptr) uintptr { + target := goString(g_variant_get_string(parameter, 0)) + g_simple_action_set_state(action, g_variant_new_string(target)) + itemId := 0 + for i := 0; i < len(target); i++ { + if target[i] < '0' || target[i] > '9' { + break + } + itemId = itemId*10 + int(target[i]-'0') + } + menuActionActivated(uint(itemId)) + return 0 +}) + +var cachedVariantTypeString uintptr + +func variantTypeString() uintptr { + if cachedVariantTypeString == 0 { + cachedVariantTypeString = g_variant_type_new("s") + } + return cachedVariantTypeString +} + +// gMenuItemNew wraps g_menu_item_new, whose action argument may be NULL — +// an empty Go string would create an item bound to an action literally named +// "", so the C string is built by hand. +func gMenuItemNew(label, action string) uintptr { + var cAction uintptr + if action != "" { + cAction = cString(action) + defer g_free(cAction) + } + return g_menu_item_new(label, cAction) +} + +func createMenuItem(label, actionName string, itemId uint) uintptr { + initAppActionGroup() + + item := gMenuItemNew(label, "app."+actionName) + + action := g_simple_action_new(actionName, 0) + signalConnect(action, "activate", onActionActivatedPtr, uintptr(itemId)) + g_action_map_add_action(appActionGroup, action) + return item +} + +func createCheckMenuItem(label, actionName string, itemId uint, initialState bool) uintptr { + initAppActionGroup() + + item := gMenuItemNew(label, "app."+actionName) + + action := g_simple_action_new_stateful(actionName, 0, g_variant_new_boolean(gbool(initialState))) + signalConnect(action, "activate", onActionActivatedPtr, uintptr(itemId)) + g_action_map_add_action(appActionGroup, action) + return item +} + +func createRadioMenuItem(label, actionName, target, initialValue string, itemId uint) uintptr { + initAppActionGroup() + + item := gMenuItemNew(label, "") + g_menu_item_set_action_and_target_value(item, "app."+actionName, g_variant_new_string(target)) + + if g_action_map_lookup_action(appActionGroup, actionName) == 0 { + action := g_simple_action_new_stateful(actionName, variantTypeString(), g_variant_new_string(initialValue)) + signalConnect(action, "activate", onRadioActionActivatedPtr, uintptr(itemId)) + g_action_map_add_action(appActionGroup, action) + } + return item +} + +func createMenuBarFromModel(menuModel uintptr) uintptr { + return gtk_popover_menu_bar_new_from_model(menuModel) +} + +func createHeaderBarWithMenu(menuModel uintptr) uintptr { + headerBar := gtk_header_bar_new() + + menuButton := gtk_menu_button_new() + gtk_menu_button_set_icon_name(menuButton, "open-menu-symbolic") + gtk_menu_button_set_menu_model(menuButton, menuModel) + gtk_widget_set_tooltip_text(menuButton, "Main Menu") + accessibleLabel(menuButton, "Main Menu") + + gtk_header_bar_pack_end(headerBar, menuButton) + return headerBar +} + +// accessibleLabel sets GTK_ACCESSIBLE_PROPERTY_LABEL on a widget. +// gtk_accessible_update_property is variadic, so use the array variant. +func accessibleLabel(widget uintptr, label string) { + // GtkAccessibleProperty (gtkenums.h): AUTOCOMPLETE=0, DESCRIPTION, + // HAS_POPUP, KEY_SHORTCUTS, LABEL=4, ... + const gtkAccessiblePropertyLabel = 4 + + var value gValue + g_value_init(uintptr(unsafe.Pointer(&value)), g_type_from_name("gchararray")) + g_value_set_string(uintptr(unsafe.Pointer(&value)), label) + properties := []int32{gtkAccessiblePropertyLabel} + gtk_accessible_update_property_value(widget, 1, + uintptr(unsafe.Pointer(&properties[0])), + uintptr(unsafe.Pointer(&value))) + g_value_unset(uintptr(unsafe.Pointer(&value))) +} + +func attachActionGroupToWidget(widget uintptr) { + initAppActionGroup() + gtk_widget_insert_action_group(widget, "app", appActionGroup) +} + +func setActionAccelerator(app uintptr, actionName, accel string) { + if app == 0 || accel == "" { + return + } + cAccel := cString(accel) + defer g_free(cAccel) + accels := []uintptr{cAccel, 0} + gtk_application_set_accels_for_action(app, "app."+actionName, uintptr(unsafe.Pointer(&accels[0]))) +} + +func setActionEnabled(actionName string, enabled bool) { + if appActionGroup == 0 { + return + } + action := g_action_map_lookup_action(appActionGroup, actionName) + if action != 0 { + g_simple_action_set_enabled(action, gbool(enabled)) + } +} + +func setActionState(actionName string, state bool) { + if appActionGroup == 0 { + return + } + action := g_action_map_lookup_action(appActionGroup, actionName) + if action != 0 { + g_simple_action_set_state(action, g_variant_new_boolean(gbool(state))) + } +} + +func getActionState(actionName string) bool { + if appActionGroup == 0 { + return false + } + action := g_action_map_lookup_action(appActionGroup, actionName) + if action == 0 { + return false + } + state := g_action_get_state(action) + if state == 0 { + return false + } + result := g_variant_get_boolean(state) != 0 + g_variant_unref(state) + return result +} + +// Context menu + +var onContextMenuClosedPtr = purego.NewCallback(func(popover, data uintptr) uintptr { + // Unparent on the next main loop iteration so the popover finishes its + // close animation/cleanup before being removed from the widget tree. + g_idle_add(unparentWidgetPtr, popover) + return 0 +}) + +var unparentWidgetPtr = purego.NewCallback(func(widget uintptr) uintptr { + gtk_widget_unparent(widget) + return gSourceRemove +}) + +func showContextMenu(parent, menuModel uintptr, x, y int) { + initAppActionGroup() + + popover := gtk_popover_menu_new_from_model(menuModel) + gtk_widget_set_parent(popover, parent) + gtk_popover_set_has_arrow(popover, 0) + gtk_popover_set_position(popover, gtkPosBottom) + + // Ensure the menu actions resolve even if the parent's hierarchy does not + // already expose the "app" action group. + gtk_widget_insert_action_group(popover, "app", appActionGroup) + + rect := gdkRectangle{x: int32(x), y: int32(y), width: 1, height: 1} + gtk_popover_set_pointing_to(popover, uintptr(unsafe.Pointer(&rect))) + + signalConnect(popover, "closed", onContextMenuClosedPtr, 0) + + gtk_popover_popup(popover) +} + +// ---------------------------------------------------------------------------- +// File dialogs (GtkFileDialog, async) +// ---------------------------------------------------------------------------- + +// The async finish callbacks receive the dialog request id via user_data — +// no allocation needed, unlike the C FileDialogData struct. + +func finishSingleFile(finish func(uintptr, uintptr, uintptr) uintptr, source, res uintptr, requestID uint) { + var gerr uintptr + file := finish(source, res, uintptr(unsafe.Pointer(&gerr))) + switch { + case gerr != 0: + g_error_free(gerr) + fileDialogCallback(requestID, nil, true) + case file != 0: + path := takeGString(g_file_get_path(file)) + g_object_unref(file) + fileDialogCallback(requestID, []string{path}, false) + default: + fileDialogCallback(requestID, nil, true) + } +} + +func finishMultipleFiles(finish func(uintptr, uintptr, uintptr) uintptr, source, res uintptr, requestID uint) { + var gerr uintptr + files := finish(source, res, uintptr(unsafe.Pointer(&gerr))) + switch { + case gerr != 0: + g_error_free(gerr) + fileDialogCallback(requestID, nil, true) + case files != 0: + n := g_list_model_get_n_items(files) + paths := make([]string, 0, n) + for i := uint32(0); i < n; i++ { + file := g_list_model_get_item(files, i) + if file == 0 { + continue + } + if p := g_file_get_path(file); p != 0 { + paths = append(paths, takeGString(p)) + } + g_object_unref(file) + } + g_object_unref(files) + fileDialogCallback(requestID, paths, false) + default: + fileDialogCallback(requestID, nil, true) + } +} + +var onFileDialogOpenFinishPtr = purego.NewCallback(func(source, res, data uintptr) uintptr { + finishSingleFile(gtk_file_dialog_open_finish, source, res, uint(data)) + return 0 +}) + +var onFileDialogOpenMultipleFinishPtr = purego.NewCallback(func(source, res, data uintptr) uintptr { + finishMultipleFiles(gtk_file_dialog_open_multiple_finish, source, res, uint(data)) + return 0 +}) + +var onFileDialogSelectFolderFinishPtr = purego.NewCallback(func(source, res, data uintptr) uintptr { + finishSingleFile(gtk_file_dialog_select_folder_finish, source, res, uint(data)) + return 0 +}) + +var onFileDialogSelectMultipleFoldersFinishPtr = purego.NewCallback(func(source, res, data uintptr) uintptr { + finishMultipleFiles(gtk_file_dialog_select_multiple_folders_finish, source, res, uint(data)) + return 0 +}) + +var onFileDialogSaveFinishPtr = purego.NewCallback(func(source, res, data uintptr) uintptr { + finishSingleFile(gtk_file_dialog_save_finish, source, res, uint(data)) + return 0 +}) + +func showOpenFileDialog(parent, dialog uintptr, requestID uint, allowMultiple, isFolder bool) { + data := uintptr(requestID) + switch { + case isFolder && allowMultiple: + gtk_file_dialog_select_multiple_folders(dialog, parent, 0, onFileDialogSelectMultipleFoldersFinishPtr, data) + case isFolder: + gtk_file_dialog_select_folder(dialog, parent, 0, onFileDialogSelectFolderFinishPtr, data) + case allowMultiple: + gtk_file_dialog_open_multiple(dialog, parent, 0, onFileDialogOpenMultipleFinishPtr, data) + default: + gtk_file_dialog_open(dialog, parent, 0, onFileDialogOpenFinishPtr, data) + } +} + +func showSaveFileDialog(parent, dialog uintptr, requestID uint) { + gtk_file_dialog_save(dialog, parent, 0, onFileDialogSaveFinishPtr, uintptr(requestID)) +} + +func addFileFilter(dialog, filters uintptr, name, pattern string) { + filter := gtk_file_filter_new() + gtk_file_filter_set_name(filter, name) + for _, p := range splitAndTrim(pattern, ";") { + if p != "" { + gtk_file_filter_add_pattern(filter, p) + } + } + g_list_store_append(filters, filter) + g_object_unref(filter) +} + +// ---------------------------------------------------------------------------- +// Message dialogs (custom GtkWindow-based, port of show_message_dialog) +// ---------------------------------------------------------------------------- + +type messageDialogState struct { + dialog uintptr + requestID uint + cancelButton int + buttons []uintptr +} + +var ( + messageDialogsLock sync.Mutex + messageDialogs = map[uintptr]*messageDialogState{} // keyed by handle id + messageDialogNext uintptr +) + +func storeMessageDialog(s *messageDialogState) uintptr { + messageDialogsLock.Lock() + defer messageDialogsLock.Unlock() + messageDialogNext++ + messageDialogs[messageDialogNext] = s + return messageDialogNext +} + +func loadMessageDialog(handle uintptr) *messageDialogState { + messageDialogsLock.Lock() + defer messageDialogsLock.Unlock() + return messageDialogs[handle] +} + +func dropMessageDialog(handle uintptr) { + messageDialogsLock.Lock() + defer messageDialogsLock.Unlock() + delete(messageDialogs, handle) +} + +var onMessageDialogButtonClickedPtr = purego.NewCallback(func(button, data uintptr) uintptr { + state := loadMessageDialog(data) + if state == nil { + return 0 + } + index := int(g_object_get_data(button, "button-index")) + alertDialogCallback(state.requestID, index) + gtk_window_destroy(state.dialog) + dropMessageDialog(data) + return 0 +}) + +var onMessageDialogClosePtr = purego.NewCallback(func(window, data uintptr) uintptr { + state := loadMessageDialog(data) + if state == nil { + return 0 + } + result := -1 + if state.cancelButton >= 0 { + result = state.cancelButton + } + alertDialogCallback(state.requestID, result) + dropMessageDialog(data) + return 0 // FALSE: allow the default close handling to destroy the window +}) + +var onMessageDialogKeyPressedPtr = purego.NewCallback(func(controller uintptr, keyval, keycode uint32, state uint32, data uintptr) uintptr { + dlgState := loadMessageDialog(data) + if dlgState == nil { + return 0 + } + if keyval == gdkKeyEscape && dlgState.cancelButton >= 0 && dlgState.cancelButton < len(dlgState.buttons) { + gtk_widget_activate(dlgState.buttons[dlgState.cancelButton]) + return 1 + } + return 0 +}) + +func showMessageDialog(parent uintptr, heading, body, iconName string, iconData []byte, + buttons []string, defaultButton, cancelButton, destructiveButton int, requestID uint) { + + dialog := gtk_window_new() + gtk_window_set_modal(dialog, 1) + gtk_window_set_resizable(dialog, 0) + gtk_window_set_decorated(dialog, 1) + gtk_widget_add_css_class(dialog, "message") + gtk_widget_set_size_request(dialog, 300, -1) + + if parent != 0 { + gtk_window_set_transient_for(dialog, parent) + } + + state := &messageDialogState{ + dialog: dialog, + requestID: requestID, + cancelButton: cancelButton, + } + handle := storeMessageDialog(state) + + content := gtk_box_new(gtkOrientationVertical, 12) + gtk_widget_set_margin_start(content, 24) + gtk_widget_set_margin_end(content, 24) + gtk_widget_set_margin_top(content, 24) + gtk_widget_set_margin_bottom(content, 24) + + const symbolicIconSize = 32 + var iconWidget uintptr + if len(iconData) > 0 { + bytes := g_bytes_new(uintptr(unsafe.Pointer(&iconData[0])), uintptr(len(iconData))) + texture := gdk_texture_new_from_bytes(bytes, 0) + g_bytes_unref(bytes) + if texture != 0 { + texSize := gdk_texture_get_width(texture) + image := gtk_image_new_from_paintable(texture) + gtk_image_set_pixel_size(image, texSize) + iconWidget = image + g_object_unref(texture) + } + } else if iconName != "" { + iconWidget = gtk_image_new_from_icon_name(iconName) + gtk_image_set_pixel_size(iconWidget, symbolicIconSize) + } + + if iconWidget != 0 { + gtk_widget_set_halign(iconWidget, gtkAlignCenter) + gtk_widget_set_margin_bottom(iconWidget, 12) + gtk_box_append(content, iconWidget) + } + + if heading != "" { + headingLabel := gtk_label_new(heading) + gtk_widget_add_css_class(headingLabel, "title-2") + gtk_widget_set_halign(headingLabel, gtkAlignCenter) + gtk_label_set_wrap(headingLabel, 1) + gtk_label_set_max_width_chars(headingLabel, 50) + gtk_box_append(content, headingLabel) + } + + if body != "" { + bodyLabel := gtk_label_new(body) + gtk_widget_set_halign(bodyLabel, gtkAlignCenter) + gtk_label_set_wrap(bodyLabel, 1) + gtk_label_set_max_width_chars(bodyLabel, 50) + gtk_widget_add_css_class(bodyLabel, "dim-label") + gtk_box_append(content, bodyLabel) + } + + if len(buttons) > 0 { + buttonBox := gtk_box_new(gtkOrientationHorizontal, 8) + gtk_widget_set_halign(buttonBox, gtkAlignCenter) + gtk_widget_set_margin_top(buttonBox, 12) + + for i, label := range buttons { + btn := gtk_button_new_with_label(label) + g_object_set_data(btn, "button-index", uintptr(i)) + signalConnect(btn, "clicked", onMessageDialogButtonClickedPtr, handle) + state.buttons = append(state.buttons, btn) + + if i == defaultButton { + gtk_widget_add_css_class(btn, "suggested-action") + gtk_widget_add_css_class(btn, "default") + } + if i == destructiveButton { + gtk_widget_add_css_class(btn, "destructive-action") + } + + gtk_box_append(buttonBox, btn) + } + + gtk_box_append(content, buttonBox) + } + + gtk_window_set_child(dialog, content) + + keyController := gtk_event_controller_key_new() + signalConnect(keyController, "key-pressed", onMessageDialogKeyPressedPtr, handle) + gtk_widget_add_controller(dialog, keyController) + + signalConnect(dialog, "close-request", onMessageDialogClosePtr, handle) + + gtk_window_present(dialog) + + if defaultButton >= 0 && defaultButton < len(state.buttons) { + gtk_window_set_default_widget(dialog, state.buttons[defaultButton]) + gtk_widget_grab_focus(state.buttons[defaultButton]) + } +} + +// ---------------------------------------------------------------------------- +// Clipboard (GTK4 async API driven to completion on the main context) +// ---------------------------------------------------------------------------- + +// Unlike the C implementation, which used static globals (racy if two reads +// ever overlapped), each read gets its own state keyed by handle. +type clipboardRead struct { + done bool + text string +} + +var ( + clipboardReadsLock sync.Mutex + clipboardReads = map[uintptr]*clipboardRead{} + clipboardReadNext uintptr +) + +var onClipboardReadFinishPtr = purego.NewCallback(func(source, res, data uintptr) uintptr { + var gerr uintptr + text := gdk_clipboard_read_text_finish(source, res, uintptr(unsafe.Pointer(&gerr))) + clipboardReadsLock.Lock() + state := clipboardReads[data] + if state != nil { + if gerr != 0 { + state.text = "" + } else if text != 0 { + state.text = goString(text) + } + state.done = true + } + clipboardReadsLock.Unlock() + if gerr != 0 { + g_error_free(gerr) + } + if text != 0 { + g_free(text) + } + return 0 +}) + +// clipboardGetTextSync reads the clipboard, iterating the default main +// context until the async result lands (this is called on the main thread, +// mirroring the cgo implementation). +func clipboardGetTextSync() string { + display := gdk_display_get_default() + if display == 0 { + return "" + } + clipboard := gdk_display_get_clipboard(display) + + state := &clipboardRead{} + clipboardReadsLock.Lock() + clipboardReadNext++ + handle := clipboardReadNext + clipboardReads[handle] = state + clipboardReadsLock.Unlock() + + gdk_clipboard_read_text_async(clipboard, 0, onClipboardReadFinishPtr, handle) + + ctx := g_main_context_default() + for { + clipboardReadsLock.Lock() + done := state.done + clipboardReadsLock.Unlock() + if done { + break + } + g_main_context_iteration(ctx, 1) + } + + clipboardReadsLock.Lock() + delete(clipboardReads, handle) + clipboardReadsLock.Unlock() + return state.text +} + +// ---------------------------------------------------------------------------- +// Window max-size enforcement +// ---------------------------------------------------------------------------- + +var onWindowSizeChangedPtr = purego.NewCallback(func(object, pspec, data uintptr) uintptr { + window := object + + // Don't clamp during fullscreen or maximize - these should bypass max size + // constraints, matching V2 behaviour where geometry hints are suspended. + if gtk_window_is_fullscreen(window) != 0 || gtk_window_is_maximized(window) != 0 { + return 0 + } + + maxW := int32(g_object_get_data(window, "wails-max-width")) + maxH := int32(g_object_get_data(window, "wails-max-height")) + if maxW <= 0 && maxH <= 0 { + return 0 + } + + w := gtk_widget_get_width(window) + h := gtk_widget_get_height(window) + + needsClamp := false + if maxW > 0 && w > maxW { + w = maxW + needsClamp = true + } + if maxH > 0 && h > maxH { + h = maxH + needsClamp = true + } + if needsClamp { + gtk_window_set_default_size(window, w, h) + } + return 0 +}) + +func windowSetMaxSize(window uintptr, maxWidth, maxHeight int) { + g_object_set_data(window, "wails-max-width", uintptr(maxWidth)) + g_object_set_data(window, "wails-max-height", uintptr(maxHeight)) + + if g_object_get_data(window, "wails-max-size-connected") == 0 { + signalConnect(window, "notify::default-width", onWindowSizeChangedPtr, 0) + signalConnect(window, "notify::default-height", onWindowSizeChangedPtr, 0) + g_object_set_data(window, "wails-max-size-connected", 1) + } +} + +// ---------------------------------------------------------------------------- +// X11 window helpers (position / always-on-top) +// ---------------------------------------------------------------------------- + +// Xlib functions are resolved with dlsym(RTLD_DEFAULT): they come from GTK4's +// already-loaded X11 backend, avoiding a hard libX11 dependency. On +// Wayland-only systems they stay nil and every helper is a no-op — matching +// the cgo implementation. +var ( + x11Once sync.Once + xMoveWindow func(uintptr, uintptr, int32, int32) int32 + xFlush func(uintptr) int32 + xTranslateCoordinates func(uintptr, uintptr, uintptr, int32, int32, uintptr, uintptr, uintptr) int32 + xSendEvent func(uintptr, uintptr, int32, int64, uintptr) int32 + xInternAtom func(uintptr, string, int32) uintptr + xDefaultRootWindow func(uintptr) uintptr + gdkX11DisplayType uintptr +) + +func resolveX11Funcs() { + x11Once.Do(func() { + reg := func(fptr any, name string) { + sym, err := purego.Dlsym(purego.RTLD_DEFAULT, name) + if err == nil && sym != 0 { + purego.RegisterFunc(fptr, sym) + } + } + reg(&xMoveWindow, "XMoveWindow") + reg(&xFlush, "XFlush") + reg(&xTranslateCoordinates, "XTranslateCoordinates") + reg(&xSendEvent, "XSendEvent") + reg(&xInternAtom, "XInternAtom") + reg(&xDefaultRootWindow, "XDefaultRootWindow") + }) +} + +// isX11Display reports whether the display is backed by X11 (the purego +// equivalent of GDK_IS_X11_DISPLAY, checked via the GObject type system — +// the GdkX11Display type only registers when the X11 backend is in use). +func isX11Display(display uintptr) bool { + if gdkX11DisplayType == 0 { + gdkX11DisplayType = g_type_from_name("GdkX11Display") + } + return gdkX11DisplayType != 0 && gTypeInstanceIsA(display, gdkX11DisplayType) +} + +func x11WindowForGtkWindow(window uintptr) (xdisplay, xwindow uintptr, ok bool) { + surface := toplevelForWindow(window) + if surface == 0 { + return 0, 0, false + } + display := gdk_surface_get_display(surface) + if !isX11Display(display) { + return 0, 0, false + } + if gdk_x11_display_get_xdisplay == nil || gdk_x11_surface_get_xid == nil { + return 0, 0, false + } + resolveX11Funcs() + return gdk_x11_display_get_xdisplay(display), gdk_x11_surface_get_xid(surface), true +} + +func windowMoveX11(window uintptr, x, y int) { + xdisplay, xwindow, ok := x11WindowForGtkWindow(window) + if !ok || xMoveWindow == nil { + return + } + xMoveWindow(xdisplay, xwindow, int32(x), int32(y)) + if xFlush != nil { + xFlush(xdisplay) + } +} + +func windowGetPositionX11(window uintptr) (int, int) { + xdisplay, xwindow, ok := x11WindowForGtkWindow(window) + if !ok || xTranslateCoordinates == nil || xDefaultRootWindow == nil { + return 0, 0 + } + root := xDefaultRootWindow(xdisplay) + var absX, absY int32 + var child uintptr + if xTranslateCoordinates(xdisplay, xwindow, root, 0, 0, + uintptr(unsafe.Pointer(&absX)), uintptr(unsafe.Pointer(&absY)), + uintptr(unsafe.Pointer(&child))) != 0 { + return int(absX), int(absY) + } + return 0, 0 +} + +const ( + substructureNotifyMask = 1 << 19 + substructureRedirectMask = 1 << 20 +) + +// xClientMessageEvent mirrors XEvent's XClientMessage member on 64-bit Linux. +// XEvent itself is a 192-byte union; pad accordingly so Xlib can copy it. +type xClientMessageEvent struct { + typ int32 + _ int32 + serial uint64 + sendEvent int32 + _ int32 + display uintptr + window uintptr + messageType uintptr + format int32 + _ int32 + dataL [5]int64 + _ [96]byte // pad to sizeof(XEvent) == 192 +} + +func windowSendAlwaysOnTopX11(window uintptr, alwaysOnTop bool) { + xdisplay, xwindow, ok := x11WindowForGtkWindow(window) + if !ok || xSendEvent == nil || xInternAtom == nil || xDefaultRootWindow == nil { + return + } + + netWmState := xInternAtom(xdisplay, "_NET_WM_STATE", 0) + netWmStateAbove := xInternAtom(xdisplay, "_NET_WM_STATE_ABOVE", 0) + root := xDefaultRootWindow(xdisplay) + + const clientMessage = 33 // X11 ClientMessage event type + xev := xClientMessageEvent{ + typ: clientMessage, + display: xdisplay, + window: xwindow, + messageType: netWmState, + format: 32, + } + if alwaysOnTop { + xev.dataL[0] = 1 // _NET_WM_STATE_ADD + } + xev.dataL[1] = int64(netWmStateAbove) + xev.dataL[3] = 1 // source: normal application + + xSendEvent(xdisplay, root, 0, substructureRedirectMask|substructureNotifyMask, + uintptr(unsafe.Pointer(&xev))) + if xFlush != nil { + xFlush(xdisplay) + } +} + +func windowSetAlwaysOnTop(window uintptr, alwaysOnTop bool) { + // Store the desired state so windowShow can re-apply it if the surface + // doesn't exist yet. Use 1=true, 2=false as sentinels (0 means never set). + sentinel := uintptr(2) + if alwaysOnTop { + sentinel = 1 + } + g_object_set_data(window, "wails-always-on-top", sentinel) + windowSendAlwaysOnTopX11(window, alwaysOnTop) +} + +// windowApplyPendingAlwaysOnTop applies a previously-set always-on-top state +// once the window surface exists. Called from windowShow after present. +func windowApplyPendingAlwaysOnTop(window uintptr) { + stored := g_object_get_data(window, "wails-always-on-top") + if stored == 0 { + return // never been set + } + windowSendAlwaysOnTopX11(window, stored == 1) +} diff --git a/v3/pkg/application/linux_purego_lib.go b/v3/pkg/application/linux_purego_lib.go new file mode 100644 index 00000000000..acf55aab6c6 --- /dev/null +++ b/v3/pkg/application/linux_purego_lib.go @@ -0,0 +1,834 @@ +//go:build linux && purego && !gtk3 && !android && !server + +package application + +// Foundation for the CGO-free Linux backend. +// +// Instead of linking GTK4/WebKitGTK at build time via cgo, every library is +// dlopen(3)ed at runtime and each C function is bound with +// purego.RegisterLibFunc. This file owns: +// +// - library loading (with per-distro soname fallbacks and an actionable +// error message when a library or symbol is missing), +// - the full function-variable registry for GLib/GObject/Gio/GTK4/ +// WebKitGTK-6.0/JavaScriptCore, +// - small helpers shared by the rest of the backend (C-string conversion, +// gboolean handling, GValue construction). +// +// Conventions: +// - All C pointers are uintptr (the shared `pointer` type aliases uintptr). +// - gboolean is int32 on the C side; helpers gbool/goBool convert. +// - char* RETURN values that the caller must g_free are declared uintptr; +// use goString()+gFree. Const char* returns are declared string (purego +// copies them, nothing to free). +// - Functions that only exist in newer library versions are registered with +// registerOptional and must be nil-checked (or capability-checked via +// haveSymbol) before use. Calling an unregistered function is a nil +// dereference; calling a missing C symbol blindly would crash — there is +// no compile-time SDK floor in a purego build, the runtime check IS the +// guard. + +import ( + "fmt" + "strings" + "unsafe" + + "github.com/ebitengine/purego" +) + +type windowPointer uintptr +type identifier uint +type pointer uintptr + +type GSList struct { + data pointer + next *GSList +} + +type GSListPointer *GSList + +const nilPointer pointer = 0 + +var nilRadioGroup GSListPointer = nil + +// ---------------------------------------------------------------------------- +// Library handles +// ---------------------------------------------------------------------------- + +var ( + libGLib uintptr // libglib-2.0 + libGObject uintptr // libgobject-2.0 + libGio uintptr // libgio-2.0 + libGtk uintptr // libgtk-4 + libWebKit uintptr // libwebkitgtk-6.0 + libJSC uintptr // libjavascriptcoregtk-6.0 + libSoup uintptr // libsoup-3.0 + libC uintptr // libc (for sigaction) + + linuxLibsErr error +) + +// soname candidates per library, most specific first. The unversioned name is +// tried last: it usually only exists when -dev packages are installed. +var linuxLibCandidates = []struct { + handle *uintptr + install string // human hint: Debian/Ubuntu package (others named in error) + names []string +}{ + {&libGLib, "libglib2.0-0", []string{"libglib-2.0.so.0", "libglib-2.0.so"}}, + {&libGObject, "libglib2.0-0", []string{"libgobject-2.0.so.0", "libgobject-2.0.so"}}, + {&libGio, "libglib2.0-0", []string{"libgio-2.0.so.0", "libgio-2.0.so"}}, + {&libGtk, "libgtk-4-1", []string{"libgtk-4.so.1", "libgtk-4.so"}}, + {&libWebKit, "libwebkitgtk-6.0-4", []string{"libwebkitgtk-6.0.so.4", "libwebkitgtk-6.0.so"}}, + {&libJSC, "libjavascriptcoregtk-6.0-1", []string{"libjavascriptcoregtk-6.0.so.1", "libjavascriptcoregtk-6.0.so"}}, + {&libSoup, "libsoup-3.0-0", []string{"libsoup-3.0.so.0", "libsoup-3.0.so"}}, + {&libC, "libc6", []string{"libc.so.6", "libc.so"}}, +} + +func dlopenFirst(names []string) (uintptr, []string) { + var errs []string + for _, name := range names { + handle, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err == nil && handle != 0 { + return handle, nil + } + if err != nil { + errs = append(errs, err.Error()) + } + } + return 0, errs +} + +// missingSymbols accumulates required symbols that failed to resolve, so the +// user gets one complete report instead of the first failure. +var missingSymbols []string + +func register(fptr any, lib uintptr, name string) { + if lib == 0 { + return // library load already failed; reported separately + } + sym, err := purego.Dlsym(lib, name) + if err != nil || sym == 0 { + missingSymbols = append(missingSymbols, name) + return + } + purego.RegisterFunc(fptr, sym) +} + +// registerOptional binds fptr only if the symbol exists in the loaded library +// version. Returns false (leaving the func nil) when absent — the caller must +// check before use. +func registerOptional(fptr any, lib uintptr, name string) bool { + if lib == 0 { + return false + } + sym, err := purego.Dlsym(lib, name) + if err != nil || sym == 0 { + return false + } + purego.RegisterFunc(fptr, sym) + return true +} + +func haveSymbol(lib uintptr, name string) bool { + if lib == 0 { + return false + } + sym, err := purego.Dlsym(lib, name) + return err == nil && sym != 0 +} + +// loadLinuxLibraries dlopens everything and registers every function variable. +// Called from init() so that mainThreadId can be captured on the startup +// thread; a failure is recorded in linuxLibsErr and reported from appNew with +// an actionable message instead of panicking at import time. +func loadLinuxLibraries() error { + var missingLibs []string + for _, lib := range linuxLibCandidates { + handle, errs := dlopenFirst(lib.names) + if handle == 0 { + detail := "" + if len(errs) > 0 { + detail = " (" + errs[len(errs)-1] + ")" + } + missingLibs = append(missingLibs, + fmt.Sprintf(" %s [Debian/Ubuntu package: %s]%s", lib.names[0], lib.install, detail)) + continue + } + *lib.handle = handle + } + if len(missingLibs) > 0 { + return fmt.Errorf("this application requires GTK4 and WebKitGTK 6.0 at runtime, "+ + "but the following libraries could not be loaded:\n%s\n"+ + "Install them via your distribution's package manager, e.g.\n"+ + " Debian/Ubuntu: sudo apt install libgtk-4-1 libwebkitgtk-6.0-4\n"+ + " Fedora: sudo dnf install gtk4 webkitgtk6.0\n"+ + " Arch: sudo pacman -S gtk4 webkitgtk-6.0", + strings.Join(missingLibs, "\n")) + } + + registerGLibFuncs() + registerGtkFuncs() + registerWebKitFuncs() + registerLibcFuncs() + + if len(missingSymbols) > 0 { + return fmt.Errorf("the installed GTK4/WebKitGTK libraries are missing required symbols: %s\n"+ + "This usually means the libraries are older than the minimum supported versions "+ + "(GTK 4.10+, WebKitGTK 2.40+). Please upgrade them", + strings.Join(missingSymbols, ", ")) + } + return nil +} + +// ---------------------------------------------------------------------------- +// GLib / GObject / Gio +// ---------------------------------------------------------------------------- + +var ( + g_thread_self func() uintptr + g_idle_add func(uintptr, uintptr) uint32 + g_timeout_add_full func(int32, uint32, uintptr, uintptr, uintptr) uint32 + g_free func(uintptr) + g_malloc0 func(uintptr) uintptr + g_strdup func(string) uintptr + g_get_application_name func() uintptr // const, do NOT free + g_set_prgname func(string) + g_main_context_default func() uintptr + g_main_context_iteration func(uintptr, int32) int32 + g_main_context_invoke func(uintptr, uintptr, uintptr) + g_slist_length func(uintptr) uint32 + g_bytes_new func(uintptr, uintptr) uintptr + g_bytes_unref func(uintptr) + g_variant_new_boolean func(int32) uintptr + g_variant_new_string func(string) uintptr + g_variant_get_boolean func(uintptr) int32 + g_variant_get_string func(uintptr, uintptr) uintptr // const return + g_variant_unref func(uintptr) + g_variant_type_new func(string) uintptr + g_variant_type_free func(uintptr) + g_error_free func(uintptr) + g_error_new_literal func(uint32, int32, string) uintptr + g_quark_from_static_string func(uintptr) uint32 + + g_object_ref func(uintptr) uintptr + g_object_unref func(uintptr) + g_object_ref_sink func(uintptr) uintptr + g_object_set_data func(uintptr, string, uintptr) + g_object_get_data func(uintptr, string) uintptr + g_object_new_with_properties func(uintptr, uint32, uintptr, uintptr) uintptr + g_signal_connect_data func(uintptr, string, uintptr, uintptr, uintptr, uint32) uint64 + g_value_init func(uintptr, uintptr) uintptr + g_value_set_object func(uintptr, uintptr) + g_value_set_string func(uintptr, string) + g_value_unset func(uintptr) + g_type_from_name func(string) uintptr + + g_application_run func(uintptr, int32, uintptr) int32 + g_application_hold func(uintptr) + g_application_release func(uintptr) + g_application_quit func(uintptr) + + g_simple_action_group_new func() uintptr + g_simple_action_new func(string, uintptr) uintptr + g_simple_action_new_stateful func(string, uintptr, uintptr) uintptr + g_simple_action_set_enabled func(uintptr, int32) + g_simple_action_set_state func(uintptr, uintptr) + g_action_map_add_action func(uintptr, uintptr) + g_action_map_lookup_action func(uintptr, string) uintptr + g_action_get_state func(uintptr) uintptr + + g_menu_new func() uintptr + g_menu_item_new func(string, uintptr) uintptr + g_menu_item_set_label func(uintptr, string) + g_menu_item_set_submenu func(uintptr, uintptr) + // g_menu_item_set_action_and_target is variadic; the _value variant + // takes the target as a GVariant instead. + g_menu_item_set_action_and_target_value func(uintptr, string, uintptr) + g_menu_append_item func(uintptr, uintptr) + g_menu_append_section func(uintptr, uintptr, uintptr) + g_menu_remove func(uintptr, int32) + g_menu_remove_all func(uintptr) + g_menu_insert_item func(uintptr, int32, uintptr) + + g_list_store_new func(uintptr) uintptr + g_list_store_append func(uintptr, uintptr) + g_list_model_get_n_items func(uintptr) uint32 + g_list_model_get_item func(uintptr, uint32) uintptr + + g_file_new_for_path func(string) uintptr + g_file_get_path func(uintptr) uintptr // transfer full + + g_unix_input_stream_new func(int32, int32) uintptr + g_input_stream_read_all func(uintptr, uintptr, uintptr, uintptr, uintptr, uintptr) int32 + g_input_stream_close func(uintptr, uintptr, uintptr) int32 + +) + +func registerGLibFuncs() { + register(&g_thread_self, libGLib, "g_thread_self") + register(&g_idle_add, libGLib, "g_idle_add") + register(&g_timeout_add_full, libGLib, "g_timeout_add_full") + register(&g_free, libGLib, "g_free") + register(&g_malloc0, libGLib, "g_malloc0") + register(&g_strdup, libGLib, "g_strdup") + register(&g_get_application_name, libGLib, "g_get_application_name") + register(&g_set_prgname, libGLib, "g_set_prgname") + register(&g_main_context_default, libGLib, "g_main_context_default") + register(&g_main_context_iteration, libGLib, "g_main_context_iteration") + register(&g_main_context_invoke, libGLib, "g_main_context_invoke") + register(&g_slist_length, libGLib, "g_slist_length") + register(&g_bytes_new, libGLib, "g_bytes_new") + register(&g_bytes_unref, libGLib, "g_bytes_unref") + register(&g_variant_new_boolean, libGLib, "g_variant_new_boolean") + register(&g_variant_new_string, libGLib, "g_variant_new_string") + register(&g_variant_get_boolean, libGLib, "g_variant_get_boolean") + register(&g_variant_get_string, libGLib, "g_variant_get_string") + register(&g_variant_unref, libGLib, "g_variant_unref") + register(&g_variant_type_new, libGLib, "g_variant_type_new") + register(&g_variant_type_free, libGLib, "g_variant_type_free") + register(&g_error_free, libGLib, "g_error_free") + register(&g_error_new_literal, libGLib, "g_error_new_literal") + register(&g_quark_from_static_string, libGLib, "g_quark_from_static_string") + + register(&g_object_ref, libGObject, "g_object_ref") + register(&g_object_unref, libGObject, "g_object_unref") + register(&g_object_ref_sink, libGObject, "g_object_ref_sink") + register(&g_object_set_data, libGObject, "g_object_set_data") + register(&g_object_get_data, libGObject, "g_object_get_data") + register(&g_object_new_with_properties, libGObject, "g_object_new_with_properties") + register(&g_signal_connect_data, libGObject, "g_signal_connect_data") + register(&g_value_init, libGObject, "g_value_init") + register(&g_value_set_object, libGObject, "g_value_set_object") + register(&g_value_unset, libGObject, "g_value_unset") + register(&g_type_from_name, libGObject, "g_type_from_name") + + register(&g_application_run, libGio, "g_application_run") + register(&g_application_hold, libGio, "g_application_hold") + register(&g_application_release, libGio, "g_application_release") + register(&g_application_quit, libGio, "g_application_quit") + register(&g_simple_action_group_new, libGio, "g_simple_action_group_new") + register(&g_simple_action_new, libGio, "g_simple_action_new") + register(&g_simple_action_new_stateful, libGio, "g_simple_action_new_stateful") + register(&g_simple_action_set_enabled, libGio, "g_simple_action_set_enabled") + register(&g_simple_action_set_state, libGio, "g_simple_action_set_state") + register(&g_action_map_add_action, libGio, "g_action_map_add_action") + register(&g_action_map_lookup_action, libGio, "g_action_map_lookup_action") + register(&g_action_get_state, libGio, "g_action_get_state") + register(&g_menu_new, libGio, "g_menu_new") + register(&g_menu_item_new, libGio, "g_menu_item_new") + register(&g_menu_item_set_label, libGio, "g_menu_item_set_label") + register(&g_menu_item_set_submenu, libGio, "g_menu_item_set_submenu") + register(&g_menu_item_set_action_and_target_value, libGio, "g_menu_item_set_action_and_target_value") + register(&g_menu_append_item, libGio, "g_menu_append_item") + register(&g_menu_append_section, libGio, "g_menu_append_section") + register(&g_menu_remove, libGio, "g_menu_remove") + register(&g_menu_remove_all, libGio, "g_menu_remove_all") + register(&g_menu_insert_item, libGio, "g_menu_insert_item") + register(&g_list_store_new, libGio, "g_list_store_new") + register(&g_list_store_append, libGio, "g_list_store_append") + register(&g_list_model_get_n_items, libGio, "g_list_model_get_n_items") + register(&g_list_model_get_item, libGio, "g_list_model_get_item") + register(&g_file_new_for_path, libGio, "g_file_new_for_path") + register(&g_file_get_path, libGio, "g_file_get_path") + register(&g_unix_input_stream_new, libGio, "g_unix_input_stream_new") + register(&g_input_stream_read_all, libGio, "g_input_stream_read_all") + register(&g_input_stream_close, libGio, "g_input_stream_close") +} + +// ---------------------------------------------------------------------------- +// GTK4 / GDK +// ---------------------------------------------------------------------------- + +var ( + gtk_application_new func(string, uint32) uintptr + gtk_application_get_active_window func(uintptr) uintptr + gtk_application_get_windows func(uintptr) *GSList + gtk_application_window_new func(uintptr) uintptr + gtk_application_set_accels_for_action func(uintptr, string, uintptr) + gtk_accelerator_name func(uint32, uint32) uintptr // transfer full + + gtk_window_new func() uintptr + gtk_window_destroy func(uintptr) + gtk_window_present func(uintptr) + gtk_window_set_title func(uintptr, string) + gtk_window_set_default_size func(uintptr, int32, int32) + gtk_window_get_default_size func(uintptr, uintptr, uintptr) + gtk_window_set_resizable func(uintptr, int32) + gtk_window_set_decorated func(uintptr, int32) + gtk_window_set_child func(uintptr, uintptr) + gtk_window_set_titlebar func(uintptr, uintptr) + gtk_window_set_modal func(uintptr, int32) + gtk_window_set_transient_for func(uintptr, uintptr) + gtk_window_set_default_widget func(uintptr, uintptr) + gtk_window_fullscreen func(uintptr) + gtk_window_unfullscreen func(uintptr) + gtk_window_maximize func(uintptr) + gtk_window_unmaximize func(uintptr) + gtk_window_minimize func(uintptr) + gtk_window_is_fullscreen func(uintptr) int32 + gtk_window_is_maximized func(uintptr) int32 + gtk_window_is_active func(uintptr) int32 + + gtk_widget_set_visible func(uintptr, int32) + gtk_widget_is_visible func(uintptr) int32 + gtk_widget_set_sensitive func(uintptr, int32) + gtk_widget_set_opacity func(uintptr, float64) + gtk_widget_set_name func(uintptr, string) + gtk_widget_set_vexpand func(uintptr, int32) + gtk_widget_set_hexpand func(uintptr, int32) + gtk_widget_set_size_request func(uintptr, int32, int32) + gtk_widget_get_width func(uintptr) int32 + gtk_widget_get_height func(uintptr) int32 + gtk_widget_get_display func(uintptr) uintptr + gtk_widget_get_native func(uintptr) uintptr + gtk_widget_add_controller func(uintptr, uintptr) + gtk_widget_add_css_class func(uintptr, string) + gtk_widget_set_parent func(uintptr, uintptr) + gtk_widget_unparent func(uintptr) + gtk_widget_insert_action_group func(uintptr, string, uintptr) + gtk_widget_set_halign func(uintptr, int32) + gtk_widget_set_margin_start func(uintptr, int32) + gtk_widget_set_margin_end func(uintptr, int32) + gtk_widget_set_margin_top func(uintptr, int32) + gtk_widget_set_margin_bottom func(uintptr, int32) + gtk_widget_set_tooltip_text func(uintptr, string) + gtk_widget_grab_focus func(uintptr) int32 + gtk_widget_activate func(uintptr) int32 + + gtk_box_new func(int32, int32) uintptr + gtk_box_append func(uintptr, uintptr) + gtk_box_prepend func(uintptr, uintptr) + + gtk_native_get_surface func(uintptr) uintptr + + gtk_event_controller_focus_new func() uintptr + gtk_event_controller_key_new func() uintptr + gtk_event_controller_set_propagation_phase func(uintptr, int32) + gtk_gesture_click_new func() uintptr + gtk_gesture_single_set_button func(uintptr, uint32) + gtk_gesture_single_get_current_button func(uintptr) uint32 + gtk_drop_controller_motion_new func() uintptr + gtk_drop_target_new func(uintptr, uint32) uintptr + + gtk_popover_menu_bar_new_from_model func(uintptr) uintptr + gtk_popover_menu_new_from_model func(uintptr) uintptr + gtk_popover_set_has_arrow func(uintptr, int32) + gtk_popover_set_position func(uintptr, int32) + gtk_popover_set_pointing_to func(uintptr, uintptr) + gtk_popover_popup func(uintptr) + gtk_header_bar_new func() uintptr + gtk_header_bar_pack_end func(uintptr, uintptr) + gtk_menu_button_new func() uintptr + gtk_menu_button_set_icon_name func(uintptr, string) + gtk_menu_button_set_menu_model func(uintptr, uintptr) + + gtk_file_dialog_new func() uintptr + gtk_file_dialog_set_title func(uintptr, string) + gtk_file_dialog_set_filters func(uintptr, uintptr) + gtk_file_dialog_set_initial_folder func(uintptr, uintptr) + gtk_file_dialog_set_accept_label func(uintptr, string) + gtk_file_dialog_open func(uintptr, uintptr, uintptr, uintptr, uintptr) + gtk_file_dialog_open_finish func(uintptr, uintptr, uintptr) uintptr + gtk_file_dialog_open_multiple func(uintptr, uintptr, uintptr, uintptr, uintptr) + gtk_file_dialog_open_multiple_finish func(uintptr, uintptr, uintptr) uintptr + gtk_file_dialog_save func(uintptr, uintptr, uintptr, uintptr, uintptr) + gtk_file_dialog_save_finish func(uintptr, uintptr, uintptr) uintptr + gtk_file_dialog_select_folder func(uintptr, uintptr, uintptr, uintptr, uintptr) + gtk_file_dialog_select_folder_finish func(uintptr, uintptr, uintptr) uintptr + gtk_file_dialog_select_multiple_folders func(uintptr, uintptr, uintptr, uintptr, uintptr) + gtk_file_dialog_select_multiple_folders_finish func(uintptr, uintptr, uintptr) uintptr + gtk_file_filter_new func() uintptr + gtk_file_filter_set_name func(uintptr, string) + gtk_file_filter_add_pattern func(uintptr, string) + gtk_file_filter_get_type func() uintptr + + gtk_button_new_with_label func(string) uintptr + gtk_label_new func(string) uintptr + gtk_label_set_wrap func(uintptr, int32) + gtk_label_set_max_width_chars func(uintptr, int32) + gtk_image_new_from_paintable func(uintptr) uintptr + gtk_image_new_from_icon_name func(string) uintptr + gtk_image_set_pixel_size func(uintptr, int32) + // gtk_accessible_update_property is variadic (unsupported by purego); + // gtk_accessible_update_property_value takes arrays instead. + gtk_accessible_update_property_value func(uintptr, int32, uintptr, uintptr) + + gdk_display_get_default func() uintptr + gdk_display_get_monitors func(uintptr) uintptr + gdk_display_get_clipboard func(uintptr) uintptr + gdk_display_get_default_seat func(uintptr) uintptr + gdk_display_get_monitor_at_surface func(uintptr, uintptr) uintptr + gdk_seat_get_pointer func(uintptr) uintptr + gdk_surface_get_display func(uintptr) uintptr + gdk_toplevel_get_state func(uintptr) uint32 + gdk_toplevel_begin_move func(uintptr, uintptr, int32, float64, float64, uint32) + gdk_toplevel_begin_resize func(uintptr, int32, uintptr, int32, float64, float64, uint32) + gdk_monitor_get_geometry func(uintptr, uintptr) + gdk_monitor_get_model func(uintptr) string // const return + gdk_monitor_get_scale_factor func(uintptr) int32 + gdk_monitor_get_scale func(uintptr) float64 // GTK 4.14+, optional + gdk_clipboard_set_text func(uintptr, string) + gdk_clipboard_read_text_async func(uintptr, uintptr, uintptr, uintptr) + gdk_clipboard_read_text_finish func(uintptr, uintptr, uintptr) uintptr + gdk_unicode_to_keyval func(uint32) uint32 + gdk_texture_new_from_bytes func(uintptr, uintptr) uintptr + gdk_texture_get_width func(uintptr) int32 + gdk_file_list_get_type func() uintptr + gdk_content_formats_contain_gtype func(uintptr, uintptr) int32 + gdk_drop_get_formats func(uintptr) uintptr + + gtk_get_major_version func() uint32 + gtk_get_minor_version func() uint32 + gtk_get_micro_version func() uint32 + + // X11 (resolved from GTK's already-loaded X11 backend, optional: absent + // on Wayland-only systems) + gdk_x11_display_get_xdisplay func(uintptr) uintptr + gdk_x11_surface_get_xid func(uintptr) uintptr +) + +func registerGtkFuncs() { + register(>k_application_new, libGtk, "gtk_application_new") + register(>k_application_get_active_window, libGtk, "gtk_application_get_active_window") + register(>k_application_get_windows, libGtk, "gtk_application_get_windows") + register(>k_application_window_new, libGtk, "gtk_application_window_new") + register(>k_application_set_accels_for_action, libGtk, "gtk_application_set_accels_for_action") + register(>k_accelerator_name, libGtk, "gtk_accelerator_name") + + register(>k_window_new, libGtk, "gtk_window_new") + register(>k_window_destroy, libGtk, "gtk_window_destroy") + register(>k_window_present, libGtk, "gtk_window_present") + register(>k_window_set_title, libGtk, "gtk_window_set_title") + register(>k_window_set_default_size, libGtk, "gtk_window_set_default_size") + register(>k_window_get_default_size, libGtk, "gtk_window_get_default_size") + register(>k_window_set_resizable, libGtk, "gtk_window_set_resizable") + register(>k_window_set_decorated, libGtk, "gtk_window_set_decorated") + register(>k_window_set_child, libGtk, "gtk_window_set_child") + register(>k_window_set_titlebar, libGtk, "gtk_window_set_titlebar") + register(>k_window_set_modal, libGtk, "gtk_window_set_modal") + register(>k_window_set_transient_for, libGtk, "gtk_window_set_transient_for") + register(>k_window_set_default_widget, libGtk, "gtk_window_set_default_widget") + register(>k_window_fullscreen, libGtk, "gtk_window_fullscreen") + register(>k_window_unfullscreen, libGtk, "gtk_window_unfullscreen") + register(>k_window_maximize, libGtk, "gtk_window_maximize") + register(>k_window_unmaximize, libGtk, "gtk_window_unmaximize") + register(>k_window_minimize, libGtk, "gtk_window_minimize") + register(>k_window_is_fullscreen, libGtk, "gtk_window_is_fullscreen") + register(>k_window_is_maximized, libGtk, "gtk_window_is_maximized") + register(>k_window_is_active, libGtk, "gtk_window_is_active") + + register(>k_widget_set_visible, libGtk, "gtk_widget_set_visible") + register(>k_widget_is_visible, libGtk, "gtk_widget_is_visible") + register(>k_widget_set_sensitive, libGtk, "gtk_widget_set_sensitive") + register(>k_widget_set_opacity, libGtk, "gtk_widget_set_opacity") + register(>k_widget_set_name, libGtk, "gtk_widget_set_name") + register(>k_widget_set_vexpand, libGtk, "gtk_widget_set_vexpand") + register(>k_widget_set_hexpand, libGtk, "gtk_widget_set_hexpand") + register(>k_widget_set_size_request, libGtk, "gtk_widget_set_size_request") + register(>k_widget_get_width, libGtk, "gtk_widget_get_width") + register(>k_widget_get_height, libGtk, "gtk_widget_get_height") + register(>k_widget_get_display, libGtk, "gtk_widget_get_display") + register(>k_widget_get_native, libGtk, "gtk_widget_get_native") + register(>k_widget_add_controller, libGtk, "gtk_widget_add_controller") + register(>k_widget_add_css_class, libGtk, "gtk_widget_add_css_class") + register(>k_widget_set_parent, libGtk, "gtk_widget_set_parent") + register(>k_widget_unparent, libGtk, "gtk_widget_unparent") + register(>k_widget_insert_action_group, libGtk, "gtk_widget_insert_action_group") + register(>k_widget_set_halign, libGtk, "gtk_widget_set_halign") + register(>k_widget_set_margin_start, libGtk, "gtk_widget_set_margin_start") + register(>k_widget_set_margin_end, libGtk, "gtk_widget_set_margin_end") + register(>k_widget_set_margin_top, libGtk, "gtk_widget_set_margin_top") + register(>k_widget_set_margin_bottom, libGtk, "gtk_widget_set_margin_bottom") + register(>k_widget_set_tooltip_text, libGtk, "gtk_widget_set_tooltip_text") + register(>k_widget_grab_focus, libGtk, "gtk_widget_grab_focus") + register(>k_widget_activate, libGtk, "gtk_widget_activate") + + register(>k_box_new, libGtk, "gtk_box_new") + register(>k_box_append, libGtk, "gtk_box_append") + register(>k_box_prepend, libGtk, "gtk_box_prepend") + register(>k_native_get_surface, libGtk, "gtk_native_get_surface") + + register(>k_event_controller_focus_new, libGtk, "gtk_event_controller_focus_new") + register(>k_event_controller_key_new, libGtk, "gtk_event_controller_key_new") + register(>k_event_controller_set_propagation_phase, libGtk, "gtk_event_controller_set_propagation_phase") + register(>k_gesture_click_new, libGtk, "gtk_gesture_click_new") + register(>k_gesture_single_set_button, libGtk, "gtk_gesture_single_set_button") + register(>k_gesture_single_get_current_button, libGtk, "gtk_gesture_single_get_current_button") + register(>k_drop_controller_motion_new, libGtk, "gtk_drop_controller_motion_new") + register(>k_drop_target_new, libGtk, "gtk_drop_target_new") + + register(>k_popover_menu_bar_new_from_model, libGtk, "gtk_popover_menu_bar_new_from_model") + register(>k_popover_menu_new_from_model, libGtk, "gtk_popover_menu_new_from_model") + register(>k_popover_set_has_arrow, libGtk, "gtk_popover_set_has_arrow") + register(>k_popover_set_position, libGtk, "gtk_popover_set_position") + register(>k_popover_set_pointing_to, libGtk, "gtk_popover_set_pointing_to") + register(>k_popover_popup, libGtk, "gtk_popover_popup") + register(>k_header_bar_new, libGtk, "gtk_header_bar_new") + register(>k_header_bar_pack_end, libGtk, "gtk_header_bar_pack_end") + register(>k_menu_button_new, libGtk, "gtk_menu_button_new") + register(>k_menu_button_set_icon_name, libGtk, "gtk_menu_button_set_icon_name") + register(>k_menu_button_set_menu_model, libGtk, "gtk_menu_button_set_menu_model") + + register(>k_file_dialog_new, libGtk, "gtk_file_dialog_new") + register(>k_file_dialog_set_title, libGtk, "gtk_file_dialog_set_title") + register(>k_file_dialog_set_filters, libGtk, "gtk_file_dialog_set_filters") + register(>k_file_dialog_set_initial_folder, libGtk, "gtk_file_dialog_set_initial_folder") + register(>k_file_dialog_set_accept_label, libGtk, "gtk_file_dialog_set_accept_label") + register(>k_file_dialog_open, libGtk, "gtk_file_dialog_open") + register(>k_file_dialog_open_finish, libGtk, "gtk_file_dialog_open_finish") + register(>k_file_dialog_open_multiple, libGtk, "gtk_file_dialog_open_multiple") + register(>k_file_dialog_open_multiple_finish, libGtk, "gtk_file_dialog_open_multiple_finish") + register(>k_file_dialog_save, libGtk, "gtk_file_dialog_save") + register(>k_file_dialog_save_finish, libGtk, "gtk_file_dialog_save_finish") + register(>k_file_dialog_select_folder, libGtk, "gtk_file_dialog_select_folder") + register(>k_file_dialog_select_folder_finish, libGtk, "gtk_file_dialog_select_folder_finish") + register(>k_file_dialog_select_multiple_folders, libGtk, "gtk_file_dialog_select_multiple_folders") + register(>k_file_dialog_select_multiple_folders_finish, libGtk, "gtk_file_dialog_select_multiple_folders_finish") + register(>k_file_filter_new, libGtk, "gtk_file_filter_new") + register(>k_file_filter_set_name, libGtk, "gtk_file_filter_set_name") + register(>k_file_filter_add_pattern, libGtk, "gtk_file_filter_add_pattern") + register(>k_file_filter_get_type, libGtk, "gtk_file_filter_get_type") + + register(>k_button_new_with_label, libGtk, "gtk_button_new_with_label") + register(>k_label_new, libGtk, "gtk_label_new") + register(>k_label_set_wrap, libGtk, "gtk_label_set_wrap") + register(>k_label_set_max_width_chars, libGtk, "gtk_label_set_max_width_chars") + register(>k_image_new_from_paintable, libGtk, "gtk_image_new_from_paintable") + register(>k_image_new_from_icon_name, libGtk, "gtk_image_new_from_icon_name") + register(>k_image_set_pixel_size, libGtk, "gtk_image_set_pixel_size") + register(>k_accessible_update_property_value, libGtk, "gtk_accessible_update_property_value") + register(&g_value_set_string, libGObject, "g_value_set_string") + + register(&gdk_display_get_default, libGtk, "gdk_display_get_default") + register(&gdk_display_get_monitors, libGtk, "gdk_display_get_monitors") + register(&gdk_display_get_clipboard, libGtk, "gdk_display_get_clipboard") + register(&gdk_display_get_default_seat, libGtk, "gdk_display_get_default_seat") + register(&gdk_display_get_monitor_at_surface, libGtk, "gdk_display_get_monitor_at_surface") + register(&gdk_seat_get_pointer, libGtk, "gdk_seat_get_pointer") + register(&gdk_surface_get_display, libGtk, "gdk_surface_get_display") + register(&gdk_toplevel_get_state, libGtk, "gdk_toplevel_get_state") + register(&gdk_toplevel_begin_move, libGtk, "gdk_toplevel_begin_move") + register(&gdk_toplevel_begin_resize, libGtk, "gdk_toplevel_begin_resize") + register(&gdk_monitor_get_geometry, libGtk, "gdk_monitor_get_geometry") + register(&gdk_monitor_get_model, libGtk, "gdk_monitor_get_model") + register(&gdk_monitor_get_scale_factor, libGtk, "gdk_monitor_get_scale_factor") + // GTK 4.14+: fractional scaling. Fall back to gdk_monitor_get_scale_factor + // (integer) on older GTK4 — see monitorScale(). + registerOptional(&gdk_monitor_get_scale, libGtk, "gdk_monitor_get_scale") + register(&gdk_clipboard_set_text, libGtk, "gdk_clipboard_set_text") + register(&gdk_clipboard_read_text_async, libGtk, "gdk_clipboard_read_text_async") + register(&gdk_clipboard_read_text_finish, libGtk, "gdk_clipboard_read_text_finish") + register(&gdk_unicode_to_keyval, libGtk, "gdk_unicode_to_keyval") + register(&gdk_texture_new_from_bytes, libGtk, "gdk_texture_new_from_bytes") + register(&gdk_texture_get_width, libGtk, "gdk_texture_get_width") + register(&gdk_file_list_get_type, libGtk, "gdk_file_list_get_type") + register(&gdk_content_formats_contain_gtype, libGtk, "gdk_content_formats_contain_gtype") + register(&gdk_drop_get_formats, libGtk, "gdk_drop_get_formats") + + register(>k_get_major_version, libGtk, "gtk_get_major_version") + register(>k_get_minor_version, libGtk, "gtk_get_minor_version") + register(>k_get_micro_version, libGtk, "gtk_get_micro_version") + + // The X11 backend symbols only exist when GTK was built with X11 support + // (absent on Wayland-only builds) — optional, nil-checked at use. + registerOptional(&gdk_x11_display_get_xdisplay, libGtk, "gdk_x11_display_get_xdisplay") + registerOptional(&gdk_x11_surface_get_xid, libGtk, "gdk_x11_surface_get_xid") +} + +// ---------------------------------------------------------------------------- +// WebKitGTK 6.0 / JavaScriptCore +// ---------------------------------------------------------------------------- + +var ( + webkit_web_view_get_type func() uintptr + webkit_web_view_get_context func(uintptr) uintptr + webkit_web_view_get_user_content_manager func(uintptr) uintptr + webkit_web_view_get_settings func(uintptr) uintptr + webkit_web_view_set_settings func(uintptr, uintptr) + webkit_web_view_load_uri func(uintptr, string) + webkit_web_view_load_alternate_html func(uintptr, string, string, string) + webkit_web_view_get_uri func(uintptr) uintptr // const return + webkit_web_view_evaluate_javascript func(uintptr, uintptr, int, uintptr, uintptr, uintptr, uintptr, uintptr) + webkit_web_view_get_zoom_level func(uintptr) float64 + webkit_web_view_set_zoom_level func(uintptr, float64) + webkit_web_view_set_background_color func(uintptr, uintptr) + webkit_web_view_get_inspector func(uintptr) uintptr + webkit_web_inspector_show func(uintptr) + webkit_settings_new func() uintptr + webkit_settings_set_hardware_acceleration_policy func(uintptr, int32) + webkit_settings_get_enable_developer_extras func(uintptr) int32 + webkit_settings_set_enable_developer_extras func(uintptr, int32) + webkit_user_content_manager_new func() uintptr + webkit_user_content_manager_register_script_message_handler func(uintptr, string, uintptr) int32 + webkit_web_context_register_uri_scheme func(uintptr, string, uintptr, uintptr, uintptr) + webkit_network_session_get_default func() uintptr + webkit_uri_scheme_request_get_web_view func(uintptr) uintptr + webkit_permission_request_allow func(uintptr) + webkit_permission_request_deny func(uintptr) + webkit_user_media_permission_is_for_audio_device func(uintptr) int32 + webkit_user_media_permission_is_for_video_device func(uintptr) int32 + webkit_get_major_version func() uint32 + webkit_get_minor_version func() uint32 + webkit_get_micro_version func() uint32 + + jsc_value_to_string func(uintptr) uintptr // transfer full + + g_type_check_instance_is_a func(uintptr, uintptr) int32 + g_type_check_value_holds func(uintptr, uintptr) int32 + g_value_get_boxed func(uintptr) uintptr + webkit_user_media_permission_request_get_type func() uintptr +) + +func registerWebKitFuncs() { + register(&webkit_web_view_get_type, libWebKit, "webkit_web_view_get_type") + register(&webkit_web_view_get_context, libWebKit, "webkit_web_view_get_context") + register(&webkit_web_view_get_user_content_manager, libWebKit, "webkit_web_view_get_user_content_manager") + register(&webkit_web_view_get_settings, libWebKit, "webkit_web_view_get_settings") + register(&webkit_web_view_set_settings, libWebKit, "webkit_web_view_set_settings") + register(&webkit_web_view_load_uri, libWebKit, "webkit_web_view_load_uri") + register(&webkit_web_view_load_alternate_html, libWebKit, "webkit_web_view_load_alternate_html") + register(&webkit_web_view_get_uri, libWebKit, "webkit_web_view_get_uri") + register(&webkit_web_view_evaluate_javascript, libWebKit, "webkit_web_view_evaluate_javascript") + register(&webkit_web_view_get_zoom_level, libWebKit, "webkit_web_view_get_zoom_level") + register(&webkit_web_view_set_zoom_level, libWebKit, "webkit_web_view_set_zoom_level") + register(&webkit_web_view_set_background_color, libWebKit, "webkit_web_view_set_background_color") + register(&webkit_web_view_get_inspector, libWebKit, "webkit_web_view_get_inspector") + register(&webkit_web_inspector_show, libWebKit, "webkit_web_inspector_show") + register(&webkit_settings_new, libWebKit, "webkit_settings_new") + register(&webkit_settings_set_hardware_acceleration_policy, libWebKit, "webkit_settings_set_hardware_acceleration_policy") + register(&webkit_settings_get_enable_developer_extras, libWebKit, "webkit_settings_get_enable_developer_extras") + register(&webkit_settings_set_enable_developer_extras, libWebKit, "webkit_settings_set_enable_developer_extras") + register(&webkit_user_content_manager_new, libWebKit, "webkit_user_content_manager_new") + register(&webkit_user_content_manager_register_script_message_handler, libWebKit, "webkit_user_content_manager_register_script_message_handler") + register(&webkit_web_context_register_uri_scheme, libWebKit, "webkit_web_context_register_uri_scheme") + register(&webkit_network_session_get_default, libWebKit, "webkit_network_session_get_default") + register(&webkit_uri_scheme_request_get_web_view, libWebKit, "webkit_uri_scheme_request_get_web_view") + register(&webkit_permission_request_allow, libWebKit, "webkit_permission_request_allow") + register(&webkit_permission_request_deny, libWebKit, "webkit_permission_request_deny") + register(&webkit_user_media_permission_is_for_audio_device, libWebKit, "webkit_user_media_permission_is_for_audio_device") + register(&webkit_user_media_permission_is_for_video_device, libWebKit, "webkit_user_media_permission_is_for_video_device") + register(&webkit_user_media_permission_request_get_type, libWebKit, "webkit_user_media_permission_request_get_type") + register(&webkit_get_major_version, libWebKit, "webkit_get_major_version") + register(&webkit_get_minor_version, libWebKit, "webkit_get_minor_version") + register(&webkit_get_micro_version, libWebKit, "webkit_get_micro_version") + + register(&jsc_value_to_string, libJSC, "jsc_value_to_string") + + register(&g_type_check_instance_is_a, libGObject, "g_type_check_instance_is_a") + register(&g_type_check_value_holds, libGObject, "g_type_check_value_holds") + register(&g_value_get_boxed, libGObject, "g_value_get_boxed") +} + +// ---------------------------------------------------------------------------- +// libc (signal-handler SA_ONSTACK fix, see linux_purego.go) +// ---------------------------------------------------------------------------- + +var libc_sigaction func(int32, uintptr, uintptr) int32 + +func registerLibcFuncs() { + register(&libc_sigaction, libC, "sigaction") +} + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +func gbool(b bool) int32 { + if b { + return 1 + } + return 0 +} + +// goString copies a NUL-terminated C string. The pointer is not freed. +func goString(c uintptr) string { + if c == 0 { + return "" + } + ptr := *(*unsafe.Pointer)(unsafe.Pointer(&c)) + n := 0 + for *(*byte)(unsafe.Add(ptr, n)) != 0 { + n++ + } + return string(unsafe.Slice((*byte)(ptr), n)) +} + +// takeGString copies a transfer-full C string and g_free()s it. +func takeGString(c uintptr) string { + if c == 0 { + return "" + } + s := goString(c) + g_free(c) + return s +} + +// cString allocates a NUL-terminated copy of s in C memory (g_strdup). Free +// with g_free. Only needed when a C string must outlive the call or sit +// inside an array/struct — plain string arguments are marshalled by purego. +func cString(s string) uintptr { + return g_strdup(s) +} + +// gValue mirrors GValue: a GType followed by 2 machine words of payload. +type gValue struct { + gtype uintptr + data [2]uint64 +} + +// gObjectNewWithObjectProperty is the purego replacement for +// g_object_new(type, prop, obj, NULL): WebKitGTK 6.0 removed +// webkit_web_view_new_with_user_content_manager, and g_object_new is +// variadic, so use the non-variadic g_object_new_with_properties. +func gObjectNewWithObjectProperty(gtype uintptr, property string, obj uintptr) uintptr { + cProp := cString(property) + defer g_free(cProp) + + var value gValue + g_value_init(uintptr(unsafe.Pointer(&value)), gtype_Object()) + g_value_set_object(uintptr(unsafe.Pointer(&value)), obj) + + names := []uintptr{cProp} + result := g_object_new_with_properties(gtype, 1, + uintptr(unsafe.Pointer(&names[0])), + uintptr(unsafe.Pointer(&value))) + g_value_unset(uintptr(unsafe.Pointer(&value))) + return result +} + +// gtype_Object returns G_TYPE_OBJECT. Fundamental types have fixed IDs +// (G_TYPE_OBJECT = 20 << 2), but resolve by name to stay ABI-agnostic. +var cachedGTypeObject uintptr + +func gtype_Object() uintptr { + if cachedGTypeObject == 0 { + cachedGTypeObject = g_type_from_name("GObject") + } + return cachedGTypeObject +} + +// gTypeInstanceIsA reports whether a GTypeInstance is of (a subtype of) the +// given GType — the purego equivalent of the WEBKIT_IS_* checking macros. +func gTypeInstanceIsA(instance, gtype uintptr) bool { + if instance == 0 || gtype == 0 { + return false + } + return g_type_check_instance_is_a(instance, gtype) != 0 +} + +// signalConnect wires a GObject signal to a purego callback: +// g_signal_connect(obj, sig, cb, data) is a macro over g_signal_connect_data. +func signalConnect(obj uintptr, sig string, cb uintptr, data uintptr) { + g_signal_connect_data(obj, sig, cb, data, 0, 0) +} + +// GdkRectangle (all gint) +type gdkRectangle struct { + x, y, width, height int32 +} + +// GdkRGBA (all float) +type gdkRGBA struct { + red, green, blue, alpha float32 +} diff --git a/v3/pkg/application/permissions_linux.go b/v3/pkg/application/permissions_linux.go index 1cc31500109..58b9dae409b 100644 --- a/v3/pkg/application/permissions_linux.go +++ b/v3/pkg/application/permissions_linux.go @@ -1,4 +1,4 @@ -//go:build linux && cgo && !android && !server +//go:build linux && (cgo || purego) && !android && !server package application diff --git a/v3/pkg/application/systemtray_linux.go b/v3/pkg/application/systemtray_linux.go index b296517538c..fc8307ee88f 100644 --- a/v3/pkg/application/systemtray_linux.go +++ b/v3/pkg/application/systemtray_linux.go @@ -6,7 +6,6 @@ Portions of this code are derived from the project: */ package application -import "C" import ( "fmt" "os" diff --git a/v3/pkg/application/webview_window_linux.go b/v3/pkg/application/webview_window_linux.go index 09903796b3d..ad301df44f9 100644 --- a/v3/pkg/application/webview_window_linux.go +++ b/v3/pkg/application/webview_window_linux.go @@ -256,7 +256,7 @@ func (w *linuxWebviewWindow) setPhysicalBounds(physicalBounds Rect) { func (w *linuxWebviewWindow) setMenu(menu *Menu) { if menu == nil { - w.gtkmenu = nil + w.gtkmenu = nilPointer return } w.parent.options.Linux.Menu = menu