From 6d09a45a30c3e30e0610255fd91a079d1628def7 Mon Sep 17 00:00:00 2001 From: stffabi Date: Tue, 12 Apr 2022 12:18:27 +0200 Subject: [PATCH] [v2] Add support for AssetsHandler (#1325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [website] Fix devserver default value doc * [v2] Add support for AssetsHandler AssetsHandler is a http.Handler delegate, which gets called as a fallback for all Non-GET requests and for GET requests for which the Assets didn’t find the file. Known Limitations on Linux: - All requests are GET requests - No request headers - No request body - No response status code, only StatusOK will be returned - No response headers Known Limitations on Windows: - Request body is leaking memory. Seems to be a bug in WebView2, investigation angoing. Most of these limitations on Linux will be fixed in the future with adding support for Webkit2Gtk 2.36.0+. * [v2, linux] Add response streaming support The complete response won’t be held anymore in memory and will be streamed to WebKit2. Co-authored-by: Lea Anthony --- v2/internal/appng/app_dev.go | 9 +- .../frontend/assetserver/assethandler.go | 142 ++++++++++------ .../frontend/assetserver/assetserver.go | 62 ++++--- v2/internal/frontend/assetserver/common.go | 17 ++ .../assetserver/content_type_sniffer.go | 42 +++++ .../desktop/common/process_request.go | 55 ------ .../frontend/desktop/common/uri_translate.go | 34 ---- .../frontend/desktop/darwin/Application.h | 2 +- .../frontend/desktop/darwin/Application.m | 7 +- .../frontend/desktop/darwin/WailsContext.h | 2 +- .../frontend/desktop/darwin/WailsContext.m | 34 +++- .../frontend/desktop/darwin/frontend.go | 127 ++++++++++---- v2/internal/frontend/desktop/darwin/main.m | 6 +- v2/internal/frontend/desktop/darwin/message.h | 2 +- .../frontend/desktop/linux/frontend.go | 89 +++++----- .../frontend/desktop/linux/responsewriter.go | 119 +++++++++++++ .../frontend/desktop/windows/frontend.go | 160 +++++++++++++----- v2/internal/frontend/devserver/devserver.go | 9 +- v2/internal/frontend/devserver/external.go | 78 +++++++++ v2/pkg/options/options.go | 2 + .../docs/guides/application-development.mdx | 9 + website/docs/reference/options.mdx | 33 +++- website/docs/reference/project-config.mdx | 2 +- .../current/reference/project-config.mdx | 2 +- 24 files changed, 731 insertions(+), 313 deletions(-) create mode 100644 v2/internal/frontend/assetserver/content_type_sniffer.go delete mode 100644 v2/internal/frontend/desktop/common/process_request.go delete mode 100644 v2/internal/frontend/desktop/common/uri_translate.go create mode 100644 v2/internal/frontend/desktop/linux/responsewriter.go create mode 100644 v2/internal/frontend/devserver/external.go diff --git a/v2/internal/appng/app_dev.go b/v2/internal/appng/app_dev.go index 575be330a..afc09e0d7 100644 --- a/v2/internal/appng/app_dev.go +++ b/v2/internal/appng/app_dev.go @@ -9,6 +9,7 @@ import ( "flag" "fmt" iofs "io/fs" + "net/url" "os" "path/filepath" @@ -114,7 +115,13 @@ func CreateApp(appoptions *options.App) (*App, error) { if devServer == "" { return nil, fmt.Errorf("Unable to use FrontendDevServerUrl without a DevServer address") } - ctx = context.WithValue(ctx, "starturl", "http://"+devServer) + + startURL, err := url.Parse("http://" + devServer) + if err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, "starturl", startURL) ctx = context.WithValue(ctx, "frontenddevserverurl", frontendDevServerURL) myLogger.Info("Serving assets from frontend DevServer URL: %s", frontendDevServerURL) diff --git a/v2/internal/frontend/assetserver/assethandler.go b/v2/internal/frontend/assetserver/assethandler.go index 2594e4eb8..c7e4ac455 100644 --- a/v2/internal/frontend/assetserver/assethandler.go +++ b/v2/internal/frontend/assetserver/assethandler.go @@ -1,8 +1,11 @@ package assetserver import ( + "bytes" "context" _ "embed" + "fmt" + "io" iofs "io/fs" "net/http" "os" @@ -18,8 +21,13 @@ import ( //go:embed defaultindex.html var defaultHTML []byte +const ( + indexHTML = "index.html" +) + type assetHandler struct { - fs iofs.FS + fs iofs.FS + handler http.Handler logger *logger.Logger @@ -33,7 +41,7 @@ func NewAsssetHandler(ctx context.Context, options *options.App) (http.Handler, return nil, err } - subDir, err := fs.FindPathToFile(vfs, "index.html") + subDir, err := fs.FindPathToFile(vfs, indexHTML) if err != nil { return nil, err } @@ -45,7 +53,8 @@ func NewAsssetHandler(ctx context.Context, options *options.App) (http.Handler, } result := &assetHandler{ - fs: vfs, + fs: vfs, + handler: options.AssetsHandler, // Check if we have been given a directory to serve assets from. // If so, this means we are in dev mode and are serving assets off disk. @@ -62,63 +71,96 @@ func NewAsssetHandler(ctx context.Context, options *options.App) (http.Handler, } func (d *assetHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - if d.fs == nil { - rw.WriteHeader(http.StatusNotFound) - return - } - - filename := strings.TrimPrefix(req.URL.Path, "/") - if d.logger != nil { - d.logger.Debug("[AssetHandler] Loading file '%s'", filename) - } - - var content []byte - var err error - switch filename { - case "", "index.html": - content, err = d.loadFile("index.html") - if err != nil { - err = nil - content = defaultHTML + handler := d.handler + if strings.EqualFold(req.Method, http.MethodGet) { + filename := strings.TrimPrefix(req.URL.Path, "/") + if filename == "" { + filename = indexHTML } - default: - content, err = d.loadFile(filename) + d.logDebug("[AssetHandler] Loading file '%s'", filename) + if err := d.serveFSFile(rw, filename); err != nil { + if os.IsNotExist(err) { + if handler != nil { + d.logDebug("[AssetHandler] File '%s' not found, serving '%s' by AssetHandler", filename, req.URL) + handler.ServeHTTP(rw, req) + } else { + rw.WriteHeader(http.StatusNotFound) + } + } else { + d.logError("[AssetHandler] Unable to load file '%s': %s", filename, err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + } + } else if handler != nil { + d.logDebug("[AssetHandler] No GET request, serving '%s' by AssetHandler", req.URL) + handler.ServeHTTP(rw, req) + } else { + rw.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// serveFile will try to load the file from the fs.FS and write it to the response +func (d *assetHandler) serveFSFile(rw http.ResponseWriter, filename string) error { + if d.fs == nil { + return os.ErrNotExist } - if os.IsNotExist(err) { - rw.WriteHeader(http.StatusNotFound) - return - } - - if err == nil { - mimeType := GetMimetype(filename, content) - rw.Header().Set(HeaderContentType, mimeType) - rw.WriteHeader(http.StatusOK) - _, err = rw.Write(content) + file, err := d.fs.Open(filename) + if err != nil && d.servingFromDisk { + for tries := 0; tries < 50; tries++ { + file, err = d.fs.Open(filename) + if err != nil { + time.Sleep(100 * time.Millisecond) + } + } } if err != nil { - rw.WriteHeader(http.StatusInternalServerError) - if d.logger != nil { - d.logger.Error("[AssetHandler] Unable to load file '%s': %s", filename, err) + if filename == indexHTML && os.IsNotExist(err) { + return serveFile(rw, filename, defaultHTML) } + return err + } + defer file.Close() + + statInfo, err := file.Stat() + if err != nil { + return err + } + + rw.Header().Set(HeaderContentLength, fmt.Sprintf("%d", statInfo.Size())) + + var buf [512]byte + n, err := file.Read(buf[:]) + if err != nil && err != io.EOF { + return err + } + + // Detect MimeType by sniffing the first 512 bytes + if contentType := GetMimetype(filename, buf[:n]); contentType != "" { + rw.Header().Set(HeaderContentType, contentType) + } + + // Write the first bytes + _, err = io.Copy(rw, bytes.NewReader(buf[:n])) + if err != nil { + return err + } + + // Copy the remaining content of the file + _, err = io.Copy(rw, file) + return err +} + +func (d *assetHandler) logDebug(message string, args ...interface{}) { + if d.logger != nil { + d.logger.Debug("[AssetHandler] "+message, args...) } } -// loadFile will try to load the file from disk. If there is an error -// it will retry until eventually it will give up and error. -func (d *assetHandler) loadFile(filename string) ([]byte, error) { - if !d.servingFromDisk { - return iofs.ReadFile(d.fs, filename) +func (d *assetHandler) logError(message string, args ...interface{}) { + if d.logger != nil { + d.logger.Error("[AssetHandler] "+message, args...) } - var result []byte - var err error - for tries := 0; tries < 50; tries++ { - result, err = iofs.ReadFile(d.fs, filename) - if err != nil { - time.Sleep(100 * time.Millisecond) - } - } - return result, err } diff --git a/v2/internal/frontend/assetserver/assetserver.go b/v2/internal/frontend/assetserver/assetserver.go index e6da09227..47aa6eb33 100644 --- a/v2/internal/frontend/assetserver/assetserver.go +++ b/v2/internal/frontend/assetserver/assetserver.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" + "strconv" "github.com/wailsapp/wails/v2/internal/frontend/runtime" "github.com/wailsapp/wails/v2/internal/logger" @@ -106,26 +106,45 @@ func (d *AssetServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } } -func (d *AssetServer) Load(filename string) ([]byte, string, error) { - // This will be removed as soon as AssetsHandler have been fully introduced. - if !strings.HasPrefix(filename, "/") { - filename = "/" + filename - } +// 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) ProcessHTTPRequest(logInfo string, rw http.ResponseWriter, reqGetter func() (*http.Request, error)) { + rw = &contentTypeSniffer{rw: rw} // Make sure we have a Content-Type sniffer - req, err := http.NewRequest(http.MethodGet, "wails://wails"+filename, nil) + req, err := reqGetter() if err != nil { - return nil, "", err + d.logError("Error processing request '%s': %s (HttpResponse=500)", logInfo, err) + + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if req.Body == nil { + req.Body = http.NoBody + } + defer req.Body.Close() + + if req.RemoteAddr == "" { + // 192.0.2.0/24 is "TEST-NET" in RFC 5737 + req.RemoteAddr = "192.0.2.1:1234" + } + + if req.RequestURI == "" && req.URL != nil { + req.RequestURI = req.URL.String() + } + + if req.ContentLength == 0 { + req.ContentLength, _ = strconv.ParseInt(req.Header.Get(HeaderContentLength), 10, 64) + } else { + req.Header.Set(HeaderContentLength, fmt.Sprintf("%d", req.ContentLength)) + } + + if host := req.Header.Get(HeaderHost); host != "" { + req.Host = host } - rw := httptest.NewRecorder() d.ServeHTTP(rw, req) - - content := rw.Body.Bytes() - mimeType := rw.HeaderMap.Get(HeaderContentType) - if mimeType == "" { - mimeType = GetMimetype(filename, content) - } - return content, mimeType, nil + rw.WriteHeader(http.StatusNotImplemented) // This is a NOP when a handler has already written and set the status } func (d *AssetServer) processIndexHTML(indexHTML []byte) ([]byte, error) { @@ -158,15 +177,8 @@ func (d *AssetServer) processIndexHTML(indexHTML []byte) ([]byte, error) { } func (d *AssetServer) writeBlob(rw http.ResponseWriter, filename string, blob []byte) { - header := rw.Header() - header.Set(HeaderContentLength, fmt.Sprintf("%d", len(blob))) - if mimeType := header.Get(HeaderContentType); mimeType == "" { - mimeType = GetMimetype(filename, blob) - header.Set(HeaderContentType, mimeType) - } - - rw.WriteHeader(http.StatusOK) - if _, err := rw.Write(blob); err != nil { + err := serveFile(rw, filename, blob) + if err != nil { d.serveError(rw, err, "Unable to write content %s", filename) } } diff --git a/v2/internal/frontend/assetserver/common.go b/v2/internal/frontend/assetserver/common.go index c1e3a5a0d..84f7bc712 100644 --- a/v2/internal/frontend/assetserver/common.go +++ b/v2/internal/frontend/assetserver/common.go @@ -3,11 +3,15 @@ package assetserver import ( "bytes" "errors" + "fmt" + "io" + "net/http" "golang.org/x/net/html" ) const ( + HeaderHost = "Host" HeaderContentType = "Content-Type" HeaderContentLength = "Content-Length" HeaderUserAgent = "User-Agent" @@ -16,6 +20,19 @@ const ( WailsUserAgentValue = "wails.io" ) +func serveFile(rw http.ResponseWriter, filename string, blob []byte) error { + header := rw.Header() + header.Set(HeaderContentLength, fmt.Sprintf("%d", len(blob))) + if mimeType := header.Get(HeaderContentType); mimeType == "" { + mimeType = GetMimetype(filename, blob) + header.Set(HeaderContentType, mimeType) + } + + rw.WriteHeader(http.StatusOK) + _, err := io.Copy(rw, bytes.NewReader(blob)) + return err +} + func createScriptNode(scriptName string) *html.Node { return &html.Node{ Type: html.ElementNode, diff --git a/v2/internal/frontend/assetserver/content_type_sniffer.go b/v2/internal/frontend/assetserver/content_type_sniffer.go new file mode 100644 index 000000000..475428ae5 --- /dev/null +++ b/v2/internal/frontend/assetserver/content_type_sniffer.go @@ -0,0 +1,42 @@ +package assetserver + +import ( + "net/http" +) + +type contentTypeSniffer struct { + rw http.ResponseWriter + + wroteHeader bool +} + +func (rw *contentTypeSniffer) Header() http.Header { + return rw.rw.Header() +} + +func (rw *contentTypeSniffer) Write(buf []byte) (int, error) { + rw.writeHeader(buf) + return rw.rw.Write(buf) +} + +func (rw *contentTypeSniffer) WriteHeader(code int) { + if rw.wroteHeader { + return + } + + rw.rw.WriteHeader(code) + rw.wroteHeader = true +} + +func (rw *contentTypeSniffer) writeHeader(b []byte) { + if rw.wroteHeader { + return + } + + m := rw.rw.Header() + if _, hasType := m[HeaderContentType]; !hasType { + m.Set(HeaderContentType, http.DetectContentType(b)) + } + + rw.WriteHeader(http.StatusOK) +} diff --git a/v2/internal/frontend/desktop/common/process_request.go b/v2/internal/frontend/desktop/common/process_request.go deleted file mode 100644 index 4ea4519d3..000000000 --- a/v2/internal/frontend/desktop/common/process_request.go +++ /dev/null @@ -1,55 +0,0 @@ -package common - -import ( - "fmt" - "net/http" - "os" - "strings" - - "github.com/wailsapp/wails/v2/internal/frontend/assetserver" -) - -type RequestRespone struct { - Body []byte - MimeType string - StatusCode int -} - -func (r RequestRespone) StatusText() string { - return http.StatusText(r.StatusCode) -} - -func (r RequestRespone) String() string { - return fmt.Sprintf("Body: '%s', StatusCode: %d", string(r.Body), r.StatusCode) -} - -func ProcessRequest(uri string, assets *assetserver.AssetServer, expectedScheme string, expectedHosts ...string) (RequestRespone, error) { - // Translate URI to file - file, err := translateUriToFile(uri, expectedScheme, expectedHosts...) - if err != nil { - if err == ErrUnexpectedHost { - body := fmt.Sprintf("expected host one of \"%s\"", strings.Join(expectedHosts, ",")) - return textResponse(body, http.StatusInternalServerError), err - } - - return RequestRespone{StatusCode: http.StatusInternalServerError}, err - } - - content, mimeType, err := assets.Load(file) - if err != nil { - statusCode := http.StatusInternalServerError - if os.IsNotExist(err) { - statusCode = http.StatusNotFound - } - return RequestRespone{StatusCode: statusCode}, err - } - - return RequestRespone{Body: content, MimeType: mimeType, StatusCode: http.StatusOK}, nil -} - -func textResponse(body string, statusCode int) RequestRespone { - if body == "" { - return RequestRespone{StatusCode: statusCode} - } - return RequestRespone{Body: []byte(body), MimeType: "text/plain;charset=UTF-8", StatusCode: statusCode} -} diff --git a/v2/internal/frontend/desktop/common/uri_translate.go b/v2/internal/frontend/desktop/common/uri_translate.go deleted file mode 100644 index 75370408b..000000000 --- a/v2/internal/frontend/desktop/common/uri_translate.go +++ /dev/null @@ -1,34 +0,0 @@ -package common - -import ( - "fmt" - "net/url" -) - -var ErrUnexpectedScheme = fmt.Errorf("unexpected scheme") -var ErrUnexpectedHost = fmt.Errorf("unexpected host") - -func translateUriToFile(uri string, expectedScheme string, expectedHosts ...string) (file string, err error) { - url, err := url.Parse(uri) - if err != nil { - return "", err - } - - if url.Scheme != expectedScheme { - return "", ErrUnexpectedScheme - } - - for _, expectedHost := range expectedHosts { - if url.Host != expectedHost { - continue - } - - filePath := url.Path - if filePath == "" { - filePath = "/" - } - return filePath, nil - } - - return "", ErrUnexpectedHost -} diff --git a/v2/internal/frontend/desktop/darwin/Application.h b/v2/internal/frontend/desktop/darwin/Application.h index 9389845a1..41423965a 100644 --- a/v2/internal/frontend/desktop/darwin/Application.h +++ b/v2/internal/frontend/desktop/darwin/Application.h @@ -42,7 +42,7 @@ void Quit(void*); const char* GetSize(void *ctx); const char* GetPosition(void *ctx); -void ProcessURLResponse(void *inctx, const char *url, int statusCode, const char *contentType, void* data, int datalength); +void ProcessURLResponse(void *inctx, const char *url, int statusCode, void *headersString, int headersStringLength, void* data, int datalength); /* Dialogs */ diff --git a/v2/internal/frontend/desktop/darwin/Application.m b/v2/internal/frontend/desktop/darwin/Application.m index 949fccaaf..da1ffac16 100644 --- a/v2/internal/frontend/desktop/darwin/Application.m +++ b/v2/internal/frontend/desktop/darwin/Application.m @@ -51,15 +51,16 @@ WailsContext* Create(const char* title, int width, int height, int frameless, in return result; } -void ProcessURLResponse(void *inctx, const char *url, int statusCode, const char *contentType, void* data, int datalength) { +void ProcessURLResponse(void *inctx, const char *url, int statusCode, void *headersString, int headersStringLength, void* data, int datalength) { WailsContext *ctx = (__bridge WailsContext*) inctx; NSString *nsurl = safeInit(url); - NSString *nsContentType = safeInit(contentType); + NSData *nsHeadersJSON = [NSData dataWithBytes:headersString length:headersStringLength]; NSData *nsdata = [NSData dataWithBytes:data length:datalength]; - [ctx processURLResponse:nsurl :statusCode :nsContentType :nsdata]; + [ctx processURLResponse:nsurl :statusCode :nsHeadersJSON :nsdata]; [nsdata release]; + [nsHeadersJSON release]; } void ExecJS(void* inctx, const char *script) { diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.h b/v2/internal/frontend/desktop/darwin/WailsContext.h index 24d2b0231..b51d62f47 100644 --- a/v2/internal/frontend/desktop/darwin/WailsContext.h +++ b/v2/internal/frontend/desktop/darwin/WailsContext.h @@ -81,7 +81,7 @@ - (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters; - (void) loadRequest:(NSString*)url; -- (void) processURLResponse:(NSString *)url :(int)statusCode :(NSString *)contentType :(NSData*)data; +- (void) processURLResponse:(NSString *)url :(int)statusCode :(NSData *)headersString :(NSData*)data; - (void) ExecJS:(NSString*)script; - (NSScreen*) getCurrentScreen; diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.m b/v2/internal/frontend/desktop/darwin/WailsContext.m index 777ba7436..b3a953a71 100644 --- a/v2/internal/frontend/desktop/darwin/WailsContext.m +++ b/v2/internal/frontend/desktop/darwin/WailsContext.m @@ -376,26 +376,44 @@ [self.webview evaluateJavaScript:script completionHandler:nil]; } -- (void) processURLResponse:(NSString *)url :(int)statusCode :(NSString *)contentType :(NSData *)data { +- (void) processURLResponse:(NSString *)url :(int)statusCode :(NSData *)headersJSON :(NSData *)data { id urlSchemeTask = self.urlRequests[url]; NSURL *nsurl = [NSURL URLWithString:url]; - NSMutableDictionary *headerFields = [NSMutableDictionary new]; - if ( ![contentType isEqualToString:@""] ) { - headerFields[@"content-type"] = contentType; - } + NSDictionary *headerFields = [NSJSONSerialization JSONObjectWithData: headersJSON options: NSJSONReadingMutableContainers error: nil]; NSHTTPURLResponse *response = [[NSHTTPURLResponse new] initWithURL:nsurl statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headerFields]; [urlSchemeTask didReceiveResponse:response]; [urlSchemeTask didReceiveData:data]; [urlSchemeTask didFinish]; [self.urlRequests removeObjectForKey:url]; [response release]; - [headerFields release]; + if (headerFields != nil) { + [headerFields release]; + } } - (void)webView:(nonnull WKWebView *)webView startURLSchemeTask:(nonnull id)urlSchemeTask { - // Do something + // This callback is run with an autorelease pool self.urlRequests[urlSchemeTask.request.URL.absoluteString] = urlSchemeTask; - processURLRequest(self, [urlSchemeTask.request.URL.absoluteString UTF8String]); + const char *url = [urlSchemeTask.request.URL.absoluteString UTF8String]; + const char *method = [urlSchemeTask.request.HTTPMethod UTF8String]; + const char *headerJSON = ""; + const void *body; + int bodyLen; + + NSData *headers = [NSJSONSerialization dataWithJSONObject: urlSchemeTask.request.allHTTPHeaderFields options:0 error: nil]; + if (headers) { + NSString* headerString = [[[NSString alloc] initWithData:headers encoding:NSUTF8StringEncoding] autorelease]; + headerJSON = [headerString UTF8String]; + } + + if (urlSchemeTask.request.HTTPBody) { + body = urlSchemeTask.request.HTTPBody.bytes; + bodyLen = urlSchemeTask.request.HTTPBody.length; + } else { + // TODO handle HTTPBodyStream + } + + processURLRequest(self, url, method, headerJSON, body, bodyLen); } - (void)webView:(nonnull WKWebView *)webView stopURLSchemeTask:(nonnull id)urlSchemeTask { diff --git a/v2/internal/frontend/desktop/darwin/frontend.go b/v2/internal/frontend/desktop/darwin/frontend.go index 09ce48a9c..12a241a38 100644 --- a/v2/internal/frontend/desktop/darwin/frontend.go +++ b/v2/internal/frontend/desktop/darwin/frontend.go @@ -14,26 +14,26 @@ package darwin */ import "C" import ( + "bytes" "context" "encoding/json" + "fmt" "html/template" + "io" "log" + "net/http" + "net/http/httptest" + "net/url" "strconv" "unsafe" "github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/frontend" "github.com/wailsapp/wails/v2/internal/frontend/assetserver" - "github.com/wailsapp/wails/v2/internal/frontend/desktop/common" "github.com/wailsapp/wails/v2/internal/logger" "github.com/wailsapp/wails/v2/pkg/options" ) -type request struct { - url *C.char - ctx unsafe.Pointer -} - var messageBuffer = make(chan string, 100) var requestBuffer = make(chan *request, 100) var callbackBuffer = make(chan uint, 10) @@ -49,39 +49,27 @@ type Frontend struct { // Assets assets *assetserver.AssetServer - startURL string + startURL *url.URL // main window handle - mainWindow *Window - bindings *binding.Bindings - dispatcher frontend.Dispatcher - servingFromDisk bool + mainWindow *Window + bindings *binding.Bindings + dispatcher frontend.Dispatcher } func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.Logger, appBindings *binding.Bindings, dispatcher frontend.Dispatcher) *Frontend { - result := &Frontend{ frontendOptions: appoptions, logger: myLogger, bindings: appBindings, dispatcher: dispatcher, ctx: ctx, - startURL: "wails://wails/", } + result.startURL, _ = url.Parse("wails://wails/") - _starturl, _ := ctx.Value("starturl").(string) - if _starturl != "" { + if _starturl, _ := ctx.Value("starturl").(*url.URL); _starturl != nil { result.startURL = _starturl } else { - // Check if we have been given a directory to serve assets from. - // If so, this means we are in dev mode and are serving assets off disk. - // We indicate this through the `servingFromDisk` flag to ensure requests - // aren't cached by WebView2 in dev mode - _assetdir := ctx.Value("assetdir") - if _assetdir != nil { - result.servingFromDisk = true - } - bindingsJSON, err := appBindings.ToJSON() if err != nil { log.Fatal(err) @@ -155,7 +143,7 @@ func (f *Frontend) Run(ctx context.Context) error { f.frontendOptions.OnStartup(f.ctx) } }() - mainWindow.Run(f.startURL) + mainWindow.Run(f.startURL.String()) return nil } @@ -299,21 +287,48 @@ func (f *Frontend) ExecJS(js string) { func (f *Frontend) processRequest(r *request) { uri := C.GoString(r.url) - res, err := common.ProcessRequest(uri, f.assets, "wails", "wails") - if err != nil { - f.logger.Error("Error processing request '%s': %s (HttpResponse=%s)", uri, err, res) + rw := httptest.NewRecorder() + f.assets.ProcessHTTPRequest( + uri, + rw, + func() (*http.Request, error) { + req, err := r.GetHttpRequest() + if err != nil { + return nil, err + } + + if req.URL.Host != f.startURL.Host { + if req.Body != nil { + req.Body.Close() + } + + return nil, fmt.Errorf("Expected host '%d' in request, but was '%s'", f.startURL.Host, req.URL.Host) + } + return req, nil + }, + ) + + header := map[string]string{} + for k := range rw.Header() { + header[k] = rw.Header().Get(k) } + headerData, _ := json.Marshal(header) var content unsafe.Pointer var contentLen int - if _contents := res.Body; _contents != nil { + if _contents := rw.Body.Bytes(); _contents != nil { content = unsafe.Pointer(&_contents[0]) contentLen = len(_contents) } - mimetype := C.CString(res.MimeType) - defer C.free(unsafe.Pointer(mimetype)) - C.ProcessURLResponse(r.ctx, r.url, C.int(res.StatusCode), mimetype, content, C.int(contentLen)) + var headers unsafe.Pointer + var headersLen int + if len(headerData) != 0 { + headers = unsafe.Pointer(&headerData[0]) + headersLen = len(headerData) + } + + C.ProcessURLResponse(r.ctx, r.url, C.int(rw.Code), headers, C.int(headersLen), content, C.int(contentLen)) } //func (f *Frontend) processSystemEvent(message string) { @@ -332,6 +347,40 @@ func (f *Frontend) processRequest(r *request) { // } //} +type request struct { + url *C.char + method string + headers string + body []byte + + ctx unsafe.Pointer +} + +func (r *request) GetHttpRequest() (*http.Request, error) { + var body io.Reader + if len(r.body) != 0 { + body = bytes.NewReader(r.body) + } + + req, err := http.NewRequest(r.method, C.GoString(r.url), body) + if err != nil { + return nil, err + } + + if r.headers != "" { + var h map[string]string + if err := json.Unmarshal([]byte(r.headers), &h); err != nil { + return nil, fmt.Errorf("Unable to unmarshal request headers: %s", err) + } + + for k, v := range h { + req.Header.Add(k, v) + } + } + + return req, nil +} + //export processMessage func processMessage(message *C.char) { goMessage := C.GoString(message) @@ -339,10 +388,18 @@ func processMessage(message *C.char) { } //export processURLRequest -func processURLRequest(ctx unsafe.Pointer, url *C.char) { +func processURLRequest(ctx unsafe.Pointer, url *C.char, method *C.char, headers *C.char, body unsafe.Pointer, bodyLen C.int) { + var goBody []byte + if bodyLen != 0 { + goBody = C.GoBytes(body, bodyLen) + } + requestBuffer <- &request{ - url: url, - ctx: ctx, + url: url, + method: C.GoString(method), + headers: C.GoString(headers), + body: goBody, + ctx: ctx, } } diff --git a/v2/internal/frontend/desktop/darwin/main.m b/v2/internal/frontend/desktop/darwin/main.m index aec075cc0..f7b0f62f0 100644 --- a/v2/internal/frontend/desktop/darwin/main.m +++ b/v2/internal/frontend/desktop/darwin/main.m @@ -29,11 +29,11 @@ void processCallback(int callbackID) { NSLog(@"Process callback %d", callbackID); } -void processURLRequest(void *ctx, const char* url) { +void processURLRequest(void *ctx, const char* url const char *method, const char *headers, const void *body, int bodyLen) { NSLog(@"processURLRequest called"); const char myByteArray[] = { 0x3c,0x68,0x31,0x3e,0x48,0x65,0x6c,0x6c,0x6f,0x20,0x57,0x6f,0x72,0x6c,0x64,0x21,0x3c,0x2f,0x68,0x31,0x3e }; - // void *inctx, const char *url, int statusCode, const char *contentType, void* data, int datalength - ProcessURLResponse(ctx, url, 200, "text/html", (void*)myByteArray, 21); + // void *inctx, const char *url, int statusCode, const char *headers, void* data, int datalength + ProcessURLResponse(ctx, url, 200, "{\"Content-Type\": \"text/html\"}", (void*)myByteArray, 21); } unsigned char _Users_username_Pictures_SaltBae_png[] = { diff --git a/v2/internal/frontend/desktop/darwin/message.h b/v2/internal/frontend/desktop/darwin/message.h index f58a390ba..f0a5f482b 100644 --- a/v2/internal/frontend/desktop/darwin/message.h +++ b/v2/internal/frontend/desktop/darwin/message.h @@ -15,7 +15,7 @@ extern "C" #endif void processMessage(const char *); -void processURLRequest(void*, const char *); +void processURLRequest(void*, const char *, const char *, const char *, const void *, int); void processMessageDialogResponse(int); void processOpenFileDialogResponse(const char*); void processSaveFileDialogResponse(const char*); diff --git a/v2/internal/frontend/desktop/linux/frontend.go b/v2/internal/frontend/desktop/linux/frontend.go index bcf445807..105bd3ec2 100644 --- a/v2/internal/frontend/desktop/linux/frontend.go +++ b/v2/internal/frontend/desktop/linux/frontend.go @@ -14,8 +14,10 @@ import "C" import ( "context" "encoding/json" + "fmt" "log" "net/http" + "net/url" "os" "runtime" "strconv" @@ -25,7 +27,6 @@ import ( "github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/frontend" "github.com/wailsapp/wails/v2/internal/frontend/assetserver" - "github.com/wailsapp/wails/v2/internal/frontend/desktop/common" "github.com/wailsapp/wails/v2/internal/logger" "github.com/wailsapp/wails/v2/pkg/options" ) @@ -41,13 +42,12 @@ type Frontend struct { // Assets assets *assetserver.AssetServer - startURL string + startURL *url.URL // main window handle - mainWindow *Window - bindings *binding.Bindings - dispatcher frontend.Dispatcher - servingFromDisk bool + mainWindow *Window + bindings *binding.Bindings + dispatcher frontend.Dispatcher } func init() { @@ -65,23 +65,12 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger. bindings: appBindings, dispatcher: dispatcher, ctx: ctx, - startURL: "wails://wails/", } + result.startURL, _ = url.Parse("wails://wails/") - _starturl, _ := ctx.Value("starturl").(string) - if _starturl != "" { + if _starturl, _ := ctx.Value("starturl").(*url.URL); _starturl != nil { result.startURL = _starturl } else { - // Check if we have been given a directory to serve assets from. - // If so, this means we are in dev mode and are serving assets off disk. - // We indicate this through the `servingFromDisk` flag to ensure requests - // aren't cached by webkit. - - _assetdir := ctx.Value("assetdir") - if _assetdir != nil { - result.servingFromDisk = true - } - bindingsJSON, err := appBindings.ToJSON() if err != nil { log.Fatal(err) @@ -93,7 +82,10 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger. } result.assets = assets - go result.startRequestProcessor() + // Start 10 processors to handle requests in parallel + for i := 0; i < 10; i++ { + go result.startRequestProcessor() + } } go result.startMessageProcessor() @@ -141,7 +133,7 @@ func (f *Frontend) Run(ctx context.Context) error { } }() - f.mainWindow.Run(f.startURL) + f.mainWindow.Run(f.startURL.String()) return nil } @@ -300,11 +292,15 @@ var requestBuffer = make(chan unsafe.Pointer, 100) func (f *Frontend) startRequestProcessor() { for request := range requestBuffer { f.processRequest(request) + C.g_object_unref(C.gpointer(request)) } } //export processURLRequest func processURLRequest(request unsafe.Pointer) { + // Increment reference counter to allow async processing, will be decremented after the processing + // has been finished by a worker. + C.g_object_ref(C.gpointer(request)) requestBuffer <- request } @@ -313,38 +309,29 @@ func (f *Frontend) processRequest(request unsafe.Pointer) { uri := C.webkit_uri_scheme_request_get_uri(req) goURI := C.GoString(uri) - res, err := common.ProcessRequest(goURI, f.assets, "wails", "wails") - if err != nil { - f.logger.Error("Error processing request '%s': %s (HttpResponse=%s)", goURI, err, res) - } + // WebKitGTK stable < 2.36 API does not support request method, request headers and request. + // Apart from request bodies, this is only available beginning with 2.36: https://webkitgtk.org/reference/webkit2gtk/stable/WebKitURISchemeResponse.html + rw := &webKitResponseWriter{req: req} + defer rw.Close() - if code := res.StatusCode; code != http.StatusOK { - message := C.CString(res.StatusText()) - gerr := C.g_error_new_literal(C.g_quark_from_string(message), C.int(code), message) - C.webkit_uri_scheme_request_finish_error(req, gerr) - C.g_error_free(gerr) - C.free(unsafe.Pointer(message)) - return - } + f.assets.ProcessHTTPRequest( + goURI, + rw, + func() (*http.Request, error) { + req, err := http.NewRequest(http.MethodGet, goURI, nil) + if err != nil { + return nil, err + } - var cContent unsafe.Pointer - bodyLen := len(res.Body) - var cLen C.long - if bodyLen > 0 { - cContent = C.malloc(C.ulong(bodyLen)) - if cContent != nil { - C.memcpy(cContent, unsafe.Pointer(&res.Body[0]), C.size_t(bodyLen)) - cLen = C.long(bodyLen) - } - } + if req.URL.Host != f.startURL.Host { + if req.Body != nil { + req.Body.Close() + } - cMimeType := C.CString(res.MimeType) - defer C.free(unsafe.Pointer(cMimeType)) + return nil, fmt.Errorf("Expected host '%d' in request, but was '%s'", f.startURL.Host, req.URL.Host) + } + + return req, nil + }) - stream := C.g_memory_input_stream_new_from_data( - cContent, - cLen, - (*[0]byte)(C.free)) - C.webkit_uri_scheme_request_finish(req, stream, cLen, cMimeType) - C.g_object_unref(C.gpointer(stream)) } diff --git a/v2/internal/frontend/desktop/linux/responsewriter.go b/v2/internal/frontend/desktop/linux/responsewriter.go new file mode 100644 index 000000000..12ebadae7 --- /dev/null +++ b/v2/internal/frontend/desktop/linux/responsewriter.go @@ -0,0 +1,119 @@ +//go:build linux +// +build linux + +package linux + +/* +#cgo linux pkg-config: gtk+-3.0 webkit2gtk-4.0 gio-unix-2.0 + +#include "gtk/gtk.h" +#include "webkit2/webkit2.h" +#include "gio/gunixinputstream.h" + +*/ +import "C" +import ( + "fmt" + "io" + "net/http" + "os" + "strconv" + "syscall" + "unsafe" + + "github.com/wailsapp/wails/v2/internal/frontend/assetserver" +) + +type webKitResponseWriter struct { + req *C.WebKitURISchemeRequest + + header http.Header + wroteHeader bool + + w io.WriteCloser + wErr error +} + +func (rw *webKitResponseWriter) Header() http.Header { + if rw.header == nil { + rw.header = http.Header{} + } + return rw.header +} + +func (rw *webKitResponseWriter) Write(buf []byte) (int, error) { + rw.WriteHeader(http.StatusOK) + if rw.wErr != nil { + return 0, rw.wErr + } + return rw.w.Write(buf) +} + +func (rw *webKitResponseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + rw.wroteHeader = true + + if code != http.StatusOK { + // WebKitGTK stable < 2.36 API does not support response headers and response statuscodes + rw.w = &nopCloser{io.Discard} + rw.finishWithError(http.StatusText(code), code) + return + } + + // We can't use os.Pipe here, because that returns files with a finalizer for closing the FD. But the control over the + // read FD is given to the InputStream and will be closed there. + // Furthermore we especially don't want to have the FD_CLOEXEC + rFD, w, err := pipe() + if err != nil { + rw.wErr = fmt.Errorf("Unable opening pipe: %s", err) + rw.finishWithError(rw.wErr.Error(), http.StatusInternalServerError) + return + } + rw.w = w + + cMimeType := C.CString(rw.Header().Get(assetserver.HeaderContentType)) + defer C.free(unsafe.Pointer(cMimeType)) + + contentLength := int64(-1) + if sLen := rw.Header().Get(assetserver.HeaderContentLength); sLen != "" { + if pLen, _ := strconv.ParseInt(sLen, 10, 64); pLen > 0 { + contentLength = pLen + } + } + + stream := C.g_unix_input_stream_new(C.int(rFD), gtkBool(true)) + C.webkit_uri_scheme_request_finish(rw.req, stream, C.long(contentLength), cMimeType) + C.g_object_unref(C.gpointer(stream)) +} + +func (rw *webKitResponseWriter) Close() { + if rw.w != nil { + rw.w.Close() + } +} + +func (rw *webKitResponseWriter) finishWithError(message string, code int) { + msg := C.CString(http.StatusText(code)) + gerr := C.g_error_new_literal(C.g_quark_from_string(msg), C.int(code), msg) + C.webkit_uri_scheme_request_finish_error(rw.req, gerr) + C.g_error_free(gerr) + C.free(unsafe.Pointer(msg)) +} + +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/v2/internal/frontend/desktop/windows/frontend.go b/v2/internal/frontend/desktop/windows/frontend.go index 3eb6ad97f..99b45910e 100644 --- a/v2/internal/frontend/desktop/windows/frontend.go +++ b/v2/internal/frontend/desktop/windows/frontend.go @@ -7,7 +7,11 @@ import ( "context" "encoding/json" "fmt" + "io" "log" + "net/http" + "net/http/httptest" + "net/url" "runtime" "strconv" "strings" @@ -16,7 +20,6 @@ import ( "github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/frontend" "github.com/wailsapp/wails/v2/internal/frontend/assetserver" - "github.com/wailsapp/wails/v2/internal/frontend/desktop/common" "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/go-webview2/pkg/edge" "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc" "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32" @@ -38,13 +41,12 @@ type Frontend struct { // Assets assets *assetserver.AssetServer - startURL string + startURL *url.URL // main window handle - mainWindow *Window - bindings *binding.Bindings - dispatcher frontend.Dispatcher - servingFromDisk bool + mainWindow *Window + bindings *binding.Bindings + dispatcher frontend.Dispatcher hasStarted bool @@ -63,26 +65,17 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger. bindings: appBindings, dispatcher: dispatcher, ctx: ctx, - startURL: "http://wails.localhost/", versionInfo: versionInfo, } - _starturl, _ := ctx.Value("starturl").(string) - if _starturl != "" { + // We currently can't use wails://wails/ as other platforms do, therefore we map the assets sever onto the following url. + result.startURL, _ = url.Parse("http://wails.localhost/") + + if _starturl, _ := ctx.Value("starturl").(*url.URL); _starturl != nil { result.startURL = _starturl return result } - // Check if we have been given a directory to serve assets from. - // If so, this means we are in dev mode and are serving assets off disk. - // We indicate this through the `servingFromDisk` flag to ensure requests - // aren't cached by WebView2 in dev mode - - _assetdir := ctx.Value("assetdir") - if _assetdir != nil { - result.servingFromDisk = true - } - bindingsJSON, err := appBindings.ToJSON() if err != nil { log.Fatal(err) @@ -372,7 +365,7 @@ func (f *Frontend) setupChromium() { chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateAllow) chromium.AddWebResourceRequestedFilter("*", edge.COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL) - chromium.Navigate(f.startURL) + chromium.Navigate(f.startURL.String()) } type EventNotify struct { @@ -403,34 +396,39 @@ func (f *Frontend) processRequest(req *edge.ICoreWebView2WebResourceRequest, arg reqHeaders.Release() } + if f.assets == nil { + // We are using the devServer let the WebView2 handle the request with its default handler + return + } + //Get the request uri, _ := req.GetUri() - - res, err := common.ProcessRequest(uri, f.assets, "http", "wails.localhost") - if err == common.ErrUnexpectedScheme { - // In this case we should let the WebView2 handle the request with its default handler + reqUri, err := url.ParseRequestURI(uri) + if err != nil { + f.logger.Error("Unable to parse equest uri %s: %s", uri, err) return - } else if err == common.ErrUnexpectedHost { - // This means file:// to something other than wails, should we prevent this? - // Maybe we should introduce an AllowList for explicitly allowing schemes and hosts, this could also be interesting - // for all other platforms to improve security. - return // Let WebView2 handle the request with its default handler - } else if err != nil { - path := strings.Replace(uri, "http://wails.localhost", "", 1) - f.logger.Error("Error processing request '%s': %s (HttpResponse=%s)", path, err, res) } + if reqUri.Scheme != f.startURL.Scheme { + // Let the WebView2 handle the request with its default handler + return + } else if reqUri.Host != f.startURL.Host { + // Let the WebView2 handle the request with its default handler + return + } + + logInfo := strings.Replace(uri, f.startURL.String(), "", 1) + + rw := httptest.NewRecorder() + f.assets.ProcessHTTPRequest(logInfo, rw, coreWebview2RequestToHttpRequest(req)) + headers := []string{} - if mimeType := res.MimeType; mimeType != "" { - headers = append(headers, "Content-Type: "+mimeType) - } - content := res.Body - if content != nil && f.servingFromDisk { - headers = append(headers, "Pragma: no-cache") + for k, v := range rw.Header() { + headers = append(headers, fmt.Sprintf("%s: %s", k, strings.Join(v, ","))) } env := f.chromium.Environment() - response, err := env.CreateWebResourceResponse(content, res.StatusCode, res.StatusText(), strings.Join(headers, "\n")) + response, err := env.CreateWebResourceResponse(rw.Body.Bytes(), rw.Code, http.StatusText(rw.Code), strings.Join(headers, "\n")) if err != nil { f.logger.Error("CreateWebResourceResponse Error: %s", err) return @@ -596,3 +594,87 @@ 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) + } + } + + 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/internal/frontend/devserver/devserver.go b/v2/internal/frontend/devserver/devserver.go index 5668fa668..a8ec98de4 100644 --- a/v2/internal/frontend/devserver/devserver.go +++ b/v2/internal/frontend/devserver/devserver.go @@ -12,7 +12,6 @@ import ( "log" "net" "net/http" - "net/http/httputil" "net/url" "strings" "sync" @@ -84,12 +83,16 @@ func (d *DevWebServer) Run(ctx context.Context) error { return err } + if externalURL.Host == "" { + return fmt.Errorf("Invalid frontend:dev:serverUrl missing protocol scheme?") + } + waitCb := func() { d.LogDebug("Waiting for frontend DevServer '%s' to be ready", externalURL) } if !checkPortIsOpen(externalURL.Host, time.Minute, waitCb) { d.logger.Error("Timeout waiting for frontend DevServer") } - assetHandler = httputil.NewSingleHostReverseProxy(externalURL) + assetHandler = newExternalDevServerAssetHandler(d.logger, externalURL, d.appoptions.AssetsHandler) } // Setup internal dev server @@ -103,7 +106,7 @@ func (d *DevWebServer) Run(ctx context.Context) error { log.Fatal(err) } - d.server.GET("/*", func(c echo.Context) error { + d.server.Any("/*", func(c echo.Context) error { assetServer.ServeHTTP(c.Response(), c.Request()) return nil }) diff --git a/v2/internal/frontend/devserver/external.go b/v2/internal/frontend/devserver/external.go new file mode 100644 index 000000000..a07b1c0d6 --- /dev/null +++ b/v2/internal/frontend/devserver/external.go @@ -0,0 +1,78 @@ +//go:build dev +// +build dev + +package devserver + +import ( + "errors" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/labstack/echo/v4" + "github.com/wailsapp/wails/v2/internal/logger" +) + +func newExternalDevServerAssetHandler(logger *logger.Logger, url *url.URL, handler http.Handler) http.Handler { + errSkipProxy := fmt.Errorf("skip proxying") + + proxy := httputil.NewSingleHostReverseProxy(url) + baseDirector := proxy.Director + proxy.Director = func(r *http.Request) { + baseDirector(r) + if logger != nil { + logger.Debug("[ExternalAssetHandler] Loading '%s'", r.URL) + } + } + + proxy.ModifyResponse = func(res *http.Response) error { + if handler == nil { + return nil + } + + if res.StatusCode == http.StatusSwitchingProtocols { + return nil + } + + if res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusMethodNotAllowed { + return errSkipProxy + } + + return nil + } + + proxy.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) { + if handler != nil && errors.Is(err, errSkipProxy) { + if logger != nil { + logger.Debug("[ExternalAssetHandler] Loading '%s' failed, using AssetHandler", r.URL) + } + handler.ServeHTTP(rw, r) + } else { + if logger != nil { + logger.Error("[ExternalAssetHandler] Proxy error: %v", err) + } + rw.WriteHeader(http.StatusBadGateway) + } + } + + e := echo.New() + e.Any("/*", + func(c echo.Context) error { + req := c.Request() + rw := c.Response() + if c.IsWebSocket() || req.Method == http.MethodGet { + proxy.ServeHTTP(rw, req) + return nil + } + + if handler != nil { + handler.ServeHTTP(rw, req) + return nil + } + + return c.NoContent(http.StatusMethodNotAllowed) + }) + + return e +} diff --git a/v2/pkg/options/options.go b/v2/pkg/options/options.go index db224a5aa..1e0b9dfca 100644 --- a/v2/pkg/options/options.go +++ b/v2/pkg/options/options.go @@ -4,6 +4,7 @@ import ( "context" "io/fs" "log" + "net/http" "runtime" "github.com/wailsapp/wails/v2/pkg/options/linux" @@ -42,6 +43,7 @@ type App struct { AlwaysOnTop bool RGBA *RGBA Assets fs.FS + AssetsHandler http.Handler Menu *menu.Menu Logger logger.Logger `json:"-"` LogLevel logger.LogLevel diff --git a/website/docs/guides/application-development.mdx b/website/docs/guides/application-development.mdx index d8dc1c7ef..039cb227d 100644 --- a/website/docs/guides/application-development.mdx +++ b/website/docs/guides/application-development.mdx @@ -161,6 +161,15 @@ The second, if given, will be executed in the `frontend` directory to build the If these 2 keys aren't given, then Wails does absolutely nothing with the frontend. It is only expecting that `embed.FS`. +### AssetsHandler + +A Wails v2 app can optionally define a `http.Handler` in the `options.App`, which allows hooking into the AssetServer to +create files on the fly or process POST/PUT requests. +GET requests are always first handled by the `assets` FS. If the FS doesn't find the requested file the request will be +forwarded to the `http.Handler` for serving. Any requests other than GET will be directly processed by the `AssetsHandler` +if specified. +It's also possible to only use the `AssetsHandler` by specifiy `nil` as the `Assets` option. + ## Built in Dev Server Running `wails dev` will start the built in dev server which will start a file watcher in your project directory. By diff --git a/website/docs/reference/options.mdx b/website/docs/reference/options.mdx index fd1421297..af3f9aa5c 100644 --- a/website/docs/reference/options.mdx +++ b/website/docs/reference/options.mdx @@ -30,6 +30,7 @@ func main() { RGBA: &options.RGBA{R: 0, G: 0, B: 0, A: 255}, AlwaysOnTop: false, Assets: assets, + AssetsHandler: assetsHandler, Menu: app.applicationMenu(), Logger: nil, LogLevel: logger.DEBUG, @@ -221,6 +222,36 @@ Type: \*embed.FS The frontend assets to be used by the application. Requires an `index.html` file. +### AssetsHandler + +Name: AssetsHandler + +Type: \*http.Handler + +The assets handler is a generic `http.Handler` which will be called for any non GET request on the assets server +and for GET requests which can not be served from the `assets` because the file is not found. + +| Value | Win | Mac | Lin | +| ----------------------------- | --- | --- | --- | +| GET | ✅ | ✅ | ✅ | +| POST | ✅ | ✅ | ❌ | +| PUT | ✅ | ✅ | ❌ | +| PATCH | ✅ | ✅ | ❌ | +| DELETE | ✅ | ✅ | ❌ | +| Request Headers | ✅ | ✅ | ❌ | +| Request Body | ✅ | ✅ | ❌ | +| Request Body Streaming | ❌ | ❌ | ❌ | +| Response StatusCodes | ✅ | ✅ | ❌ | +| Response Headers | ✅ | ✅ | ❌ | +| Response Body | ✅ | ✅ | ✅ | +| Response Body Streaming | ❌ | ❌ | ✅ | + +NOTE: Linux is currently very limited due to targeting a WebKit2GTK Version < 2.36.0. In the future some features will be +supported by the introduction of WebKit2GTK 2.36.0+ support. + +NOTE: When used in combination with a Frontend DevServer there might be limitations, eg. Vite serves the index.html +on every path, that does not contain a file extension. + ### Menu Name: Menu @@ -316,7 +347,7 @@ Defines how the window should present itself at startup. | --------------- | --- | --- | --- | | Fullscreen | ✅ | ✅ | ✅ | | Maximised | ✅ | ✅ | ✅ | -| Minimised | ✅ | | ✅ | +| Minimised | ✅ | ❌ | ✅ | ### Bind diff --git a/website/docs/reference/project-config.mdx b/website/docs/reference/project-config.mdx index f09a48465..178504a63 100644 --- a/website/docs/reference/project-config.mdx +++ b/website/docs/reference/project-config.mdx @@ -20,7 +20,7 @@ The project config resides in the `wails.json` file in the project directory. Th "version": "[Project config version]", "outputfilename": "[The name of the binary]", "debounceMS": 100, // The default time the dev server waits to reload when it detects a vhange in assets - "devServer": "[Address to bind the wails dev sever to. Default: http://localhost:34115]", + "devServer": "[Address to bind the wails dev sever to. Default: localhost:34115]", "appargs": "[Arguments passed to the application in shell style when in dev mode]", "runNonNativeBuildHooks": false, // Defines if build hooks should be run though they are defined for an OS other than the host OS. "postBuildHooks": { diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/project-config.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/project-config.mdx index 407d693be..199dea830 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/project-config.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/project-config.mdx @@ -20,7 +20,7 @@ sidebar_position: 5 "version": "[项目配置版本]", "outputfilename": "[二进制文件的名称]", "debounceMS": 100, // 在检测到资源更改时,开发服务器等待重新加载的时间 - "devServer": "[将 wails 开发服务器绑定到的地址。默认:http://localhost:34115]", + "devServer": "[将 wails 开发服务器绑定到的地址。默认:localhost:34115]", "appargs": "[在dev模式下以shell样式传递给应用程序的参数]", "runNonNativeBuildHooks": false, // 定义构建钩子是否应该运行,尽管它们是为主机操作系统以外的操作系统定义的。 "postBuildHooks": {