diff --git a/v2/internal/frontend/desktop/windows/frontend.go b/v2/internal/frontend/desktop/windows/frontend.go index 55e613af6..e51be56cb 100644 --- a/v2/internal/frontend/desktop/windows/frontend.go +++ b/v2/internal/frontend/desktop/windows/frontend.go @@ -7,11 +7,8 @@ import ( "context" "encoding/json" "fmt" - "io" "log" "net" - "net/http" - "net/http/httptest" "net/url" "os" "runtime" @@ -31,6 +28,7 @@ import ( "github.com/wailsapp/wails/v2/internal/logger" "github.com/wailsapp/wails/v2/internal/system/operatingsystem" "github.com/wailsapp/wails/v2/pkg/assetserver" + "github.com/wailsapp/wails/v2/pkg/assetserver/webview" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/windows" ) @@ -612,36 +610,31 @@ func (f *Frontend) processRequest(req *edge.ICoreWebView2WebResourceRequest, arg return } - rw := httptest.NewRecorder() - f.assets.ProcessHTTPRequestLegacy(rw, coreWebview2RequestToHttpRequest(req)) + webviewRequest, err := webview.NewRequest( + f.chromium.Environment(), + args, + func(fn func()) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if f.mainWindow.InvokeRequired() { + var wg sync.WaitGroup + wg.Add(1) + f.mainWindow.Invoke(func() { + fn() + wg.Done() + }) + wg.Wait() + } else { + fn() + } + }) - headers := []string{} - for k, v := range rw.Header() { - headers = append(headers, fmt.Sprintf("%s: %s", k, strings.Join(v, ","))) - } - - code := rw.Code - if code == http.StatusNotModified { - // WebView2 has problems when a request returns a 304 status code and the WebView2 is going to hang for other - // requests including IPC calls. - f.logger.Error("%s: AssetServer returned 304 - StatusNotModified which are going to hang WebView2, changed code to 505 - StatusInternalServerError", uri) - code = http.StatusInternalServerError - } - - env := f.chromium.Environment() - response, err := env.CreateWebResourceResponse(rw.Body.Bytes(), code, http.StatusText(code), strings.Join(headers, "\n")) if err != nil { - f.logger.Error("CreateWebResourceResponse Error: %s", err) + f.logger.Error("%s: NewRequest failed: %s", uri, err) return } - defer response.Release() - // Send response back - err = args.PutResponse(response) - if err != nil { - f.logger.Error("PutResponse Error: %s", err) - return - } + f.assets.ServeWebViewRequest(webviewRequest) } var edgeMap = map[string]uintptr{ @@ -832,93 +825,3 @@ func (f *Frontend) ShowWindow() { func (f *Frontend) onFocus(arg *winc.Event) { f.chromium.Focus() } - -func coreWebview2RequestToHttpRequest(coreReq *edge.ICoreWebView2WebResourceRequest) func() (*http.Request, error) { - return func() (r *http.Request, err error) { - header := http.Header{} - headers, err := coreReq.GetHeaders() - if err != nil { - return nil, fmt.Errorf("GetHeaders Error: %s", err) - } - defer headers.Release() - - headersIt, err := headers.GetIterator() - if err != nil { - return nil, fmt.Errorf("GetIterator Error: %s", err) - } - defer headersIt.Release() - - for { - has, err := headersIt.HasCurrentHeader() - if err != nil { - return nil, fmt.Errorf("HasCurrentHeader Error: %s", err) - } - if !has { - break - } - - name, value, err := headersIt.GetCurrentHeader() - if err != nil { - return nil, fmt.Errorf("GetCurrentHeader Error: %s", err) - } - - header.Set(name, value) - if _, err := headersIt.MoveNext(); err != nil { - return nil, fmt.Errorf("MoveNext Error: %s", err) - } - } - - // WebView2 has problems when a request returns a 304 status code and the WebView2 is going to hang for other - // requests including IPC calls. - // So prevent 304 status codes by removing the headers that are used in combinationwith caching. - header.Del("If-Modified-Since") - header.Del("If-None-Match") - - method, err := coreReq.GetMethod() - if err != nil { - return nil, fmt.Errorf("GetMethod Error: %s", err) - } - - uri, err := coreReq.GetUri() - if err != nil { - return nil, fmt.Errorf("GetUri Error: %s", err) - } - - var body io.ReadCloser - if content, err := coreReq.GetContent(); err != nil { - return nil, fmt.Errorf("GetContent Error: %s", err) - } else if content != nil { - body = &iStreamReleaseCloser{stream: content} - } - - req, err := http.NewRequest(method, uri, body) - if err != nil { - if body != nil { - body.Close() - } - return nil, err - } - req.Header = header - return req, nil - } -} - -type iStreamReleaseCloser struct { - stream *edge.IStream - closed bool -} - -func (i *iStreamReleaseCloser) Read(p []byte) (int, error) { - if i.closed { - return 0, io.ErrClosedPipe - } - return i.stream.Read(p) -} - -func (i *iStreamReleaseCloser) Close() error { - if i.closed { - return nil - } - i.closed = true - return i.stream.Release() -} diff --git a/v2/pkg/assetserver/assetserver_legacy.go b/v2/pkg/assetserver/assetserver_legacy.go deleted file mode 100644 index 4df671bc2..000000000 --- a/v2/pkg/assetserver/assetserver_legacy.go +++ /dev/null @@ -1,78 +0,0 @@ -package assetserver - -import ( - "io" - "net/http" - - "github.com/wailsapp/wails/v2/pkg/assetserver/webview" -) - -// ProcessHTTPRequest processes the HTTP Request by faking a golang HTTP Server. -// The request will be finished with a StatusNotImplemented code if no handler has written to the response. -func (d *AssetServer) ProcessHTTPRequestLegacy(rw http.ResponseWriter, reqGetter func() (*http.Request, error)) { - d.processWebViewRequest(&legacyRequest{reqGetter: reqGetter, rw: rw}) -} - -type legacyRequest struct { - req *http.Request - rw http.ResponseWriter - - reqGetter func() (*http.Request, error) -} - -func (r *legacyRequest) URL() (string, error) { - req, err := r.request() - if err != nil { - return "", err - } - return req.URL.String(), nil -} - -func (r *legacyRequest) Method() (string, error) { - req, err := r.request() - if err != nil { - return "", err - } - return req.Method, nil -} - -func (r *legacyRequest) Header() (http.Header, error) { - req, err := r.request() - if err != nil { - return nil, err - } - return req.Header, nil -} - -func (r *legacyRequest) Body() (io.ReadCloser, error) { - req, err := r.request() - if err != nil { - return nil, err - } - return req.Body, nil -} - -func (r legacyRequest) Response() webview.ResponseWriter { - return &legacyRequestNoOpCloserResponseWriter{r.rw} -} - -func (r legacyRequest) Close() error { return nil } - -func (r *legacyRequest) request() (*http.Request, error) { - if r.req != nil { - return r.req, nil - } - - req, err := r.reqGetter() - if err != nil { - return nil, err - } - r.req = req - return req, nil -} - -type legacyRequestNoOpCloserResponseWriter struct { - http.ResponseWriter -} - -func (*legacyRequestNoOpCloserResponseWriter) Finish() {} diff --git a/v2/pkg/assetserver/assetserver_webview.go b/v2/pkg/assetserver/assetserver_webview.go index 7fad1b01e..575c81bb1 100644 --- a/v2/pkg/assetserver/assetserver_webview.go +++ b/v2/pkg/assetserver/assetserver_webview.go @@ -26,19 +26,15 @@ type assetServerWebView struct { func (d *AssetServer) ServeWebViewRequest(req webview.Request) { d.dispatchInit.Do(func() { workers := d.dispatchWorkers - if workers == 0 { - workers = 10 + if workers <= 0 { + return } workerC := make(chan webview.Request, workers*2) for i := 0; i < workers; i++ { go func() { for req := range workerC { - uri, _ := req.URL() d.processWebViewRequest(req) - if err := req.Close(); err != nil { - d.logError("Unable to call close for request for uri '%s'", uri) - } } }() } @@ -49,19 +45,38 @@ func (d *AssetServer) ServeWebViewRequest(req webview.Request) { d.dispatchReqC = dispatchC }) - d.dispatchReqC <- req + if d.dispatchReqC == nil { + go d.processWebViewRequest(req) + } else { + d.dispatchReqC <- req + } +} + +func (d *AssetServer) processWebViewRequest(r webview.Request) { + uri, _ := r.URL() + d.processWebViewRequestInternal(r) + if err := r.Close(); err != nil { + d.logError("Unable to call close for request for uri '%s'", uri) + } } // processHTTPRequest processes the HTTP Request by faking a golang HTTP Server. // The request will be finished with a StatusNotImplemented code if no handler has written to the response. -func (d *AssetServer) processWebViewRequest(r webview.Request) { +func (d *AssetServer) processWebViewRequestInternal(r webview.Request) { + uri := "unknown" + var err error + wrw := r.Response() - defer wrw.Finish() + defer func() { + if err := wrw.Finish(); err != nil { + d.logError("Error finishing request '%s': %s", uri, err) + } + }() var rw http.ResponseWriter = &contentTypeSniffer{rw: wrw} // Make sure we have a Content-Type sniffer defer rw.WriteHeader(http.StatusNotImplemented) // This is a NOP when a handler has already written and set the status - uri, err := r.URL() + uri, err = r.URL() if err != nil { d.logError("Error processing request, unable to get URL: %s (HttpResponse=500)", err) http.Error(rw, err.Error(), http.StatusInternalServerError) diff --git a/v2/pkg/assetserver/webview/request_windows.go b/v2/pkg/assetserver/webview/request_windows.go new file mode 100644 index 000000000..3085aaf3b --- /dev/null +++ b/v2/pkg/assetserver/webview/request_windows.go @@ -0,0 +1,216 @@ +//go:build windows +// +build windows + +package webview + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/wailsapp/go-webview2/pkg/edge" +) + +// NewRequest creates as new WebViewRequest for chromium. This Method must be called from the Main-Thread! +func NewRequest(env *edge.ICoreWebView2Environment, args *edge.ICoreWebView2WebResourceRequestedEventArgs, invokeSync func(fn func())) (Request, error) { + req, err := args.GetRequest() + if err != nil { + return nil, fmt.Errorf("GetRequest failed: %s", err) + } + defer req.Release() + + r := &request{ + invokeSync: invokeSync, + } + + r.response, err = env.CreateWebResourceResponse(nil, http.StatusInternalServerError, "") + if err != nil { + return nil, fmt.Errorf("CreateWebResourceResponse failed: %s", err) + } + + if err := args.PutResponse(r.response); err != nil { + r.finishResponse() + return nil, fmt.Errorf("PutResponse failed: %s", err) + } + + r.deferral, err = args.GetDeferral() + if err != nil { + r.finishResponse() + return nil, fmt.Errorf("GetDeferral failed: %s", err) + } + + r.url, r.urlErr = req.GetUri() + r.method, r.methodErr = req.GetMethod() + r.header, r.headerErr = getHeaders(req) + + if content, err := req.GetContent(); err != nil { + r.bodyErr = err + } else if content != nil { + // It is safe to access Content from another Thread: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/threading-model#thread-safety + r.body = &iStreamReleaseCloser{stream: content} + } + + return r, nil +} + +var _ Request = &request{} + +type request struct { + response *edge.ICoreWebView2WebResourceResponse + deferral *edge.ICoreWebView2Deferral + + url string + urlErr error + + method string + methodErr error + + header http.Header + headerErr error + + body io.ReadCloser + bodyErr error + rw *responseWriter + + invokeSync func(fn func()) +} + +func (r *request) URL() (string, error) { + return r.url, r.urlErr +} + +func (r *request) Method() (string, error) { + return r.method, r.methodErr +} + +func (r *request) Header() (http.Header, error) { + return r.header, r.headerErr +} + +func (r *request) Body() (io.ReadCloser, error) { + return r.body, r.bodyErr +} + +func (r *request) Response() ResponseWriter { + if r.rw != nil { + return r.rw + } + + r.rw = &responseWriter{req: r} + return r.rw +} + +func (r *request) Close() error { + var errs []error + if r.body != nil { + if err := r.body.Close(); err != nil { + errs = append(errs, err) + } + r.body = nil + } + + if err := r.Response().Finish(); err != nil { + errs = append(errs, err) + } + + return combineErrs(errs) +} + +// finishResponse must be called on the main-thread +func (r *request) finishResponse() error { + var errs []error + if r.response != nil { + if err := r.response.Release(); err != nil { + errs = append(errs, err) + } + r.response = nil + } + if r.deferral != nil { + if err := r.deferral.Complete(); err != nil { + errs = append(errs, err) + } + + if err := r.deferral.Release(); err != nil { + errs = append(errs, err) + } + r.deferral = nil + } + return combineErrs(errs) +} + +type iStreamReleaseCloser struct { + stream *edge.IStream + closed bool +} + +func (i *iStreamReleaseCloser) Read(p []byte) (int, error) { + if i.closed { + return 0, io.ErrClosedPipe + } + return i.stream.Read(p) +} + +func (i *iStreamReleaseCloser) Close() error { + if i.closed { + return nil + } + i.closed = true + return i.stream.Release() +} + +func getHeaders(req *edge.ICoreWebView2WebResourceRequest) (http.Header, error) { + header := http.Header{} + headers, err := req.GetHeaders() + if err != nil { + return nil, fmt.Errorf("GetHeaders Error: %s", err) + } + defer headers.Release() + + headersIt, err := headers.GetIterator() + if err != nil { + return nil, fmt.Errorf("GetIterator Error: %s", err) + } + defer headersIt.Release() + + for { + has, err := headersIt.HasCurrentHeader() + if err != nil { + return nil, fmt.Errorf("HasCurrentHeader Error: %s", err) + } + if !has { + break + } + + name, value, err := headersIt.GetCurrentHeader() + if err != nil { + return nil, fmt.Errorf("GetCurrentHeader Error: %s", err) + } + + header.Set(name, value) + if _, err := headersIt.MoveNext(); err != nil { + return nil, fmt.Errorf("MoveNext Error: %s", err) + } + } + + // WebView2 has problems when a request returns a 304 status code and the WebView2 is going to hang for other + // requests including IPC calls. + // So prevent 304 status codes by removing the headers that are used in combinationwith caching. + header.Del("If-Modified-Since") + header.Del("If-None-Match") + return header, nil +} + +func combineErrs(errs []error) error { + // TODO use Go1.20 errors.Join + if len(errs) == 0 { + return nil + } + + errStrings := make([]string, len(errs)) + for i, err := range errs { + errStrings[i] = err.Error() + } + + return fmt.Errorf(strings.Join(errStrings, "\n")) +} diff --git a/v2/pkg/assetserver/webview/responsewriter.go b/v2/pkg/assetserver/webview/responsewriter.go index d67802a05..dacbb567d 100644 --- a/v2/pkg/assetserver/webview/responsewriter.go +++ b/v2/pkg/assetserver/webview/responsewriter.go @@ -21,5 +21,5 @@ type ResponseWriter interface { http.ResponseWriter // Finish the response and flush all data. A Finish after the request has already been finished has no effect. - Finish() + Finish() error } diff --git a/v2/pkg/assetserver/webview/responsewriter_darwin.go b/v2/pkg/assetserver/webview/responsewriter_darwin.go index 1c0cbee72..77de3c455 100644 --- a/v2/pkg/assetserver/webview/responsewriter_darwin.go +++ b/v2/pkg/assetserver/webview/responsewriter_darwin.go @@ -133,15 +133,16 @@ func (rw *responseWriter) WriteHeader(code int) { C.URLSchemeTaskDidReceiveResponse(rw.r.task, C.int(code), headers, C.int(headersLen)) } -func (rw *responseWriter) Finish() { +func (rw *responseWriter) Finish() error { if !rw.wroteHeader { rw.WriteHeader(http.StatusNotImplemented) } if rw.finished { - return + return nil } rw.finished = true C.URLSchemeTaskDidFinish(rw.r.task) + return nil } diff --git a/v2/pkg/assetserver/webview/responsewriter_linux.go b/v2/pkg/assetserver/webview/responsewriter_linux.go index 9b3f53a78..52e28aa5d 100644 --- a/v2/pkg/assetserver/webview/responsewriter_linux.go +++ b/v2/pkg/assetserver/webview/responsewriter_linux.go @@ -84,18 +84,19 @@ func (rw *responseWriter) WriteHeader(code int) { } } -func (rw *responseWriter) Finish() { +func (rw *responseWriter) Finish() error { if !rw.wroteHeader { rw.WriteHeader(http.StatusNotImplemented) } if rw.finished { - return + return nil } rw.finished = true if rw.w != nil { rw.w.Close() } + return nil } func (rw *responseWriter) finishWithError(code int, err error) { diff --git a/v2/pkg/assetserver/webview/responsewriter_windows.go b/v2/pkg/assetserver/webview/responsewriter_windows.go new file mode 100644 index 000000000..748d9511b --- /dev/null +++ b/v2/pkg/assetserver/webview/responsewriter_windows.go @@ -0,0 +1,105 @@ +//go:build windows +// +build windows + +package webview + +import ( + "bytes" + "fmt" + "net/http" + "strings" +) + +var _ http.ResponseWriter = &responseWriter{} + +type responseWriter struct { + req *request + + header http.Header + wroteHeader bool + code int + body *bytes.Buffer + + finished bool +} + +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) + + return rw.body.Write(buf) +} + +func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader || rw.finished { + return + } + rw.wroteHeader = true + + if rw.body == nil { + rw.body = &bytes.Buffer{} + } + + rw.code = code +} + +func (rw *responseWriter) Finish() error { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusNotImplemented) + } + + if rw.finished { + return nil + } + rw.finished = true + + var errs []error + + code := rw.code + if code == http.StatusNotModified { + // WebView2 has problems when a request returns a 304 status code and the WebView2 is going to hang for other + // requests including IPC calls. + errs = append(errs, fmt.Errorf("AssetServer returned 304 - StatusNotModified which are going to hang WebView2, changed code to 505 - StatusInternalServerError")) + code = http.StatusInternalServerError + } + + rw.req.invokeSync(func() { + resp := rw.req.response + + hdrs, err := resp.GetHeaders() + if err != nil { + errs = append(errs, fmt.Errorf("Resp.GetHeaders failed: %s", err)) + } else { + for k, v := range rw.header { + if err := hdrs.AppendHeader(k, strings.Join(v, ",")); err != nil { + errs = append(errs, fmt.Errorf("Resp.AppendHeader failed: %s", err)) + } + } + hdrs.Release() + } + + if err := resp.PutStatusCode(code); err != nil { + errs = append(errs, fmt.Errorf("Resp.PutStatusCode failed: %s", err)) + } + + if err := resp.PutByteContent(rw.body.Bytes()); err != nil { + errs = append(errs, fmt.Errorf("Resp.PutByteContent failed: %s", err)) + } + + if err := rw.req.finishResponse(); err != nil { + errs = append(errs, fmt.Errorf("Resp.finishResponse failed: %s", err)) + } + }) + + return combineErrs(errs) +} diff --git a/website/src/pages/changelog.mdx b/website/src/pages/changelog.mdx index 77ceb3f8c..9f8cf8559 100644 --- a/website/src/pages/changelog.mdx +++ b/website/src/pages/changelog.mdx @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for enabling/disabling swipe gestures for Windows WebView2. Added by @leaanthony in [PR](https://github.com/wailsapp/wails/pull/2878) - When building with `-devtools` flag, CMD/CTRL+SHIFT+F12 can be used to open the devtools. Added by @leaanthony in [PR](https://github.com/wailsapp/wails/pull/2915) +### Changed + +- AssetServer requests are now processed asynchronously without blocking the main thread on Windows. Changed by @stffabi in [PR](https://github.com/wailsapp/wails/pull/2926) +- AssetServer requests are now processed concurrently by spawning a goroutine per request. Changed by @stffabi in [PR](https://github.com/wailsapp/wails/pull/2926) + #### Fixed - Fixed typo on docs/reference/options page. Added by [@pylotlight](https://github.com/pylotlight) in [PR](https://github.com/wailsapp/wails/pull/2887)