Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions v3/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions v3/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion v3/internal/assetserver/webview/mainthread_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && !android
//go:build linux && cgo && !android && !purego

package webview

Expand Down
190 changes: 190 additions & 0 deletions v3/internal/assetserver/webview/mainthread_linux_purego.go
Original file line number Diff line number Diff line change
@@ -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()
}
2 changes: 1 addition & 1 deletion v3/internal/assetserver/webview/mainthread_linux_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && !android
//go:build linux && cgo && !android && !purego

package webview

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && !android
//go:build linux && cgo && !android && !purego

package webview

Expand Down
2 changes: 1 addition & 1 deletion v3/internal/assetserver/webview/request_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && !gtk3 && !android
//go:build linux && cgo && !gtk3 && !android && !purego

package webview

Expand Down
2 changes: 1 addition & 1 deletion v3/internal/assetserver/webview/request_linux_gtk3.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && gtk3 && !android
//go:build linux && cgo && gtk3 && !android && !purego

package webview

Expand Down
108 changes: 108 additions & 0 deletions v3/internal/assetserver/webview/request_linux_purego.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion v3/internal/assetserver/webview/responsewriter_linux.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && !gtk3 && !android
//go:build linux && cgo && !gtk3 && !android && !purego

package webview

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && cgo && gtk3 && !android
//go:build linux && cgo && gtk3 && !android && !purego

package webview

Expand Down
Loading
Loading