5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-03 06:20:48 +08:00

[v2] Add support for AssetsHandler (#1325)

* [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 <lea.anthony@gmail.com>
This commit is contained in:
stffabi 2022-04-12 12:18:27 +02:00 committed by GitHub
parent 3fbe4f71c4
commit 6d09a45a30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 731 additions and 313 deletions

View File

@ -9,6 +9,7 @@ import (
"flag" "flag"
"fmt" "fmt"
iofs "io/fs" iofs "io/fs"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
@ -114,7 +115,13 @@ func CreateApp(appoptions *options.App) (*App, error) {
if devServer == "" { if devServer == "" {
return nil, fmt.Errorf("Unable to use FrontendDevServerUrl without a DevServer address") 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) ctx = context.WithValue(ctx, "frontenddevserverurl", frontendDevServerURL)
myLogger.Info("Serving assets from frontend DevServer URL: %s", frontendDevServerURL) myLogger.Info("Serving assets from frontend DevServer URL: %s", frontendDevServerURL)

View File

@ -1,8 +1,11 @@
package assetserver package assetserver
import ( import (
"bytes"
"context" "context"
_ "embed" _ "embed"
"fmt"
"io"
iofs "io/fs" iofs "io/fs"
"net/http" "net/http"
"os" "os"
@ -18,8 +21,13 @@ import (
//go:embed defaultindex.html //go:embed defaultindex.html
var defaultHTML []byte var defaultHTML []byte
const (
indexHTML = "index.html"
)
type assetHandler struct { type assetHandler struct {
fs iofs.FS fs iofs.FS
handler http.Handler
logger *logger.Logger logger *logger.Logger
@ -33,7 +41,7 @@ func NewAsssetHandler(ctx context.Context, options *options.App) (http.Handler,
return nil, err return nil, err
} }
subDir, err := fs.FindPathToFile(vfs, "index.html") subDir, err := fs.FindPathToFile(vfs, indexHTML)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -46,6 +54,7 @@ func NewAsssetHandler(ctx context.Context, options *options.App) (http.Handler,
result := &assetHandler{ result := &assetHandler{
fs: vfs, fs: vfs,
handler: options.AssetsHandler,
// Check if we have been given a directory to serve assets from. // 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. // 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) { func (d *assetHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if d.fs == nil { handler := d.handler
rw.WriteHeader(http.StatusNotFound) if strings.EqualFold(req.Method, http.MethodGet) {
return
}
filename := strings.TrimPrefix(req.URL.Path, "/") filename := strings.TrimPrefix(req.URL.Path, "/")
if d.logger != nil { if filename == "" {
d.logger.Debug("[AssetHandler] Loading file '%s'", filename) filename = indexHTML
}
var content []byte
var err error
switch filename {
case "", "index.html":
content, err = d.loadFile("index.html")
if err != nil {
err = nil
content = defaultHTML
}
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 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) rw.WriteHeader(http.StatusNotFound)
return
} }
} else {
if err == nil { d.logError("[AssetHandler] Unable to load file '%s': %s", filename, err)
mimeType := GetMimetype(filename, content) http.Error(rw, err.Error(), http.StatusInternalServerError)
rw.Header().Set(HeaderContentType, mimeType)
rw.WriteHeader(http.StatusOK)
_, err = rw.Write(content)
} }
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
if d.logger != nil {
d.logger.Error("[AssetHandler] Unable to load file '%s': %s", filename, err)
} }
} 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)
} }
} }
// loadFile will try to load the file from disk. If there is an error // serveFile will try to load the file from the fs.FS and write it to the response
// it will retry until eventually it will give up and error. func (d *assetHandler) serveFSFile(rw http.ResponseWriter, filename string) error {
func (d *assetHandler) loadFile(filename string) ([]byte, error) { if d.fs == nil {
if !d.servingFromDisk { return os.ErrNotExist
return iofs.ReadFile(d.fs, filename)
} }
var result []byte
var err error file, err := d.fs.Open(filename)
if err != nil && d.servingFromDisk {
for tries := 0; tries < 50; tries++ { for tries := 0; tries < 50; tries++ {
result, err = iofs.ReadFile(d.fs, filename) file, err = d.fs.Open(filename)
if err != nil { if err != nil {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
} }
return result, err }
if err != nil {
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...)
}
}
func (d *assetHandler) logError(message string, args ...interface{}) {
if d.logger != nil {
d.logger.Error("[AssetHandler] "+message, args...)
}
} }

View File

@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strconv"
"github.com/wailsapp/wails/v2/internal/frontend/runtime" "github.com/wailsapp/wails/v2/internal/frontend/runtime"
"github.com/wailsapp/wails/v2/internal/logger" "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) { // ProcessHTTPRequest processes the HTTP Request by faking a golang HTTP Server.
// This will be removed as soon as AssetsHandler have been fully introduced. // The request will be finished with a StatusNotImplemented code if no handler has written to the response.
if !strings.HasPrefix(filename, "/") { func (d *AssetServer) ProcessHTTPRequest(logInfo string, rw http.ResponseWriter, reqGetter func() (*http.Request, error)) {
filename = "/" + filename 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 { 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) d.ServeHTTP(rw, req)
rw.WriteHeader(http.StatusNotImplemented) // This is a NOP when a handler has already written and set the status
content := rw.Body.Bytes()
mimeType := rw.HeaderMap.Get(HeaderContentType)
if mimeType == "" {
mimeType = GetMimetype(filename, content)
}
return content, mimeType, nil
} }
func (d *AssetServer) processIndexHTML(indexHTML []byte) ([]byte, error) { 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) { func (d *AssetServer) writeBlob(rw http.ResponseWriter, filename string, blob []byte) {
header := rw.Header() err := serveFile(rw, filename, blob)
header.Set(HeaderContentLength, fmt.Sprintf("%d", len(blob))) if err != nil {
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 {
d.serveError(rw, err, "Unable to write content %s", filename) d.serveError(rw, err, "Unable to write content %s", filename)
} }
} }

View File

@ -3,11 +3,15 @@ package assetserver
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"io"
"net/http"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
const ( const (
HeaderHost = "Host"
HeaderContentType = "Content-Type" HeaderContentType = "Content-Type"
HeaderContentLength = "Content-Length" HeaderContentLength = "Content-Length"
HeaderUserAgent = "User-Agent" HeaderUserAgent = "User-Agent"
@ -16,6 +20,19 @@ const (
WailsUserAgentValue = "wails.io" 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 { func createScriptNode(scriptName string) *html.Node {
return &html.Node{ return &html.Node{
Type: html.ElementNode, Type: html.ElementNode,

View File

@ -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)
}

View File

@ -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}
}

View File

@ -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
}

View File

@ -42,7 +42,7 @@ void Quit(void*);
const char* GetSize(void *ctx); const char* GetSize(void *ctx);
const char* GetPosition(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 */ /* Dialogs */

View File

@ -51,15 +51,16 @@ WailsContext* Create(const char* title, int width, int height, int frameless, in
return result; 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; WailsContext *ctx = (__bridge WailsContext*) inctx;
NSString *nsurl = safeInit(url); NSString *nsurl = safeInit(url);
NSString *nsContentType = safeInit(contentType); NSData *nsHeadersJSON = [NSData dataWithBytes:headersString length:headersStringLength];
NSData *nsdata = [NSData dataWithBytes:data length:datalength]; NSData *nsdata = [NSData dataWithBytes:data length:datalength];
[ctx processURLResponse:nsurl :statusCode :nsContentType :nsdata]; [ctx processURLResponse:nsurl :statusCode :nsHeadersJSON :nsdata];
[nsdata release]; [nsdata release];
[nsHeadersJSON release];
} }
void ExecJS(void* inctx, const char *script) { void ExecJS(void* inctx, const char *script) {

View File

@ -81,7 +81,7 @@
- (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters; - (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters;
- (void) loadRequest:(NSString*)url; - (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; - (void) ExecJS:(NSString*)script;
- (NSScreen*) getCurrentScreen; - (NSScreen*) getCurrentScreen;

View File

@ -376,26 +376,44 @@
[self.webview evaluateJavaScript:script completionHandler:nil]; [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<WKURLSchemeTask> urlSchemeTask = self.urlRequests[url]; id<WKURLSchemeTask> urlSchemeTask = self.urlRequests[url];
NSURL *nsurl = [NSURL URLWithString:url]; NSURL *nsurl = [NSURL URLWithString:url];
NSMutableDictionary *headerFields = [NSMutableDictionary new]; NSDictionary *headerFields = [NSJSONSerialization JSONObjectWithData: headersJSON options: NSJSONReadingMutableContainers error: nil];
if ( ![contentType isEqualToString:@""] ) {
headerFields[@"content-type"] = contentType;
}
NSHTTPURLResponse *response = [[NSHTTPURLResponse new] initWithURL:nsurl statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headerFields]; NSHTTPURLResponse *response = [[NSHTTPURLResponse new] initWithURL:nsurl statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headerFields];
[urlSchemeTask didReceiveResponse:response]; [urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data]; [urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish]; [urlSchemeTask didFinish];
[self.urlRequests removeObjectForKey:url]; [self.urlRequests removeObjectForKey:url];
[response release]; [response release];
if (headerFields != nil) {
[headerFields release]; [headerFields release];
} }
}
- (void)webView:(nonnull WKWebView *)webView startURLSchemeTask:(nonnull id<WKURLSchemeTask>)urlSchemeTask { - (void)webView:(nonnull WKWebView *)webView startURLSchemeTask:(nonnull id<WKURLSchemeTask>)urlSchemeTask {
// Do something // This callback is run with an autorelease pool
self.urlRequests[urlSchemeTask.request.URL.absoluteString] = urlSchemeTask; 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<WKURLSchemeTask>)urlSchemeTask { - (void)webView:(nonnull WKWebView *)webView stopURLSchemeTask:(nonnull id<WKURLSchemeTask>)urlSchemeTask {

View File

@ -14,26 +14,26 @@ package darwin
*/ */
import "C" import "C"
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"html/template" "html/template"
"io"
"log" "log"
"net/http"
"net/http/httptest"
"net/url"
"strconv" "strconv"
"unsafe" "unsafe"
"github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/binding"
"github.com/wailsapp/wails/v2/internal/frontend" "github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/assetserver" "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/internal/logger"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
) )
type request struct {
url *C.char
ctx unsafe.Pointer
}
var messageBuffer = make(chan string, 100) var messageBuffer = make(chan string, 100)
var requestBuffer = make(chan *request, 100) var requestBuffer = make(chan *request, 100)
var callbackBuffer = make(chan uint, 10) var callbackBuffer = make(chan uint, 10)
@ -49,39 +49,27 @@ type Frontend struct {
// Assets // Assets
assets *assetserver.AssetServer assets *assetserver.AssetServer
startURL string startURL *url.URL
// main window handle // main window handle
mainWindow *Window mainWindow *Window
bindings *binding.Bindings bindings *binding.Bindings
dispatcher frontend.Dispatcher dispatcher frontend.Dispatcher
servingFromDisk bool
} }
func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.Logger, appBindings *binding.Bindings, dispatcher frontend.Dispatcher) *Frontend { func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.Logger, appBindings *binding.Bindings, dispatcher frontend.Dispatcher) *Frontend {
result := &Frontend{ result := &Frontend{
frontendOptions: appoptions, frontendOptions: appoptions,
logger: myLogger, logger: myLogger,
bindings: appBindings, bindings: appBindings,
dispatcher: dispatcher, dispatcher: dispatcher,
ctx: ctx, ctx: ctx,
startURL: "wails://wails/",
} }
result.startURL, _ = url.Parse("wails://wails/")
_starturl, _ := ctx.Value("starturl").(string) if _starturl, _ := ctx.Value("starturl").(*url.URL); _starturl != nil {
if _starturl != "" {
result.startURL = _starturl result.startURL = _starturl
} else { } 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() bindingsJSON, err := appBindings.ToJSON()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -155,7 +143,7 @@ func (f *Frontend) Run(ctx context.Context) error {
f.frontendOptions.OnStartup(f.ctx) f.frontendOptions.OnStartup(f.ctx)
} }
}() }()
mainWindow.Run(f.startURL) mainWindow.Run(f.startURL.String())
return nil return nil
} }
@ -299,21 +287,48 @@ func (f *Frontend) ExecJS(js string) {
func (f *Frontend) processRequest(r *request) { func (f *Frontend) processRequest(r *request) {
uri := C.GoString(r.url) uri := C.GoString(r.url)
res, err := common.ProcessRequest(uri, f.assets, "wails", "wails") rw := httptest.NewRecorder()
f.assets.ProcessHTTPRequest(
uri,
rw,
func() (*http.Request, error) {
req, err := r.GetHttpRequest()
if err != nil { if err != nil {
f.logger.Error("Error processing request '%s': %s (HttpResponse=%s)", uri, err, res) 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 content unsafe.Pointer
var contentLen int var contentLen int
if _contents := res.Body; _contents != nil { if _contents := rw.Body.Bytes(); _contents != nil {
content = unsafe.Pointer(&_contents[0]) content = unsafe.Pointer(&_contents[0])
contentLen = len(_contents) 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) { //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 //export processMessage
func processMessage(message *C.char) { func processMessage(message *C.char) {
goMessage := C.GoString(message) goMessage := C.GoString(message)
@ -339,9 +388,17 @@ func processMessage(message *C.char) {
} }
//export processURLRequest //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{ requestBuffer <- &request{
url: url, url: url,
method: C.GoString(method),
headers: C.GoString(headers),
body: goBody,
ctx: ctx, ctx: ctx,
} }
} }

View File

@ -29,11 +29,11 @@ void processCallback(int callbackID) {
NSLog(@"Process callback %d", 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"); 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 }; 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 // void *inctx, const char *url, int statusCode, const char *headers, void* data, int datalength
ProcessURLResponse(ctx, url, 200, "text/html", (void*)myByteArray, 21); ProcessURLResponse(ctx, url, 200, "{\"Content-Type\": \"text/html\"}", (void*)myByteArray, 21);
} }
unsigned char _Users_username_Pictures_SaltBae_png[] = { unsigned char _Users_username_Pictures_SaltBae_png[] = {

View File

@ -15,7 +15,7 @@ extern "C"
#endif #endif
void processMessage(const char *); 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 processMessageDialogResponse(int);
void processOpenFileDialogResponse(const char*); void processOpenFileDialogResponse(const char*);
void processSaveFileDialogResponse(const char*); void processSaveFileDialogResponse(const char*);

View File

@ -14,8 +14,10 @@ import "C"
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"runtime" "runtime"
"strconv" "strconv"
@ -25,7 +27,6 @@ import (
"github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/binding"
"github.com/wailsapp/wails/v2/internal/frontend" "github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/assetserver" "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/internal/logger"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
) )
@ -41,13 +42,12 @@ type Frontend struct {
// Assets // Assets
assets *assetserver.AssetServer assets *assetserver.AssetServer
startURL string startURL *url.URL
// main window handle // main window handle
mainWindow *Window mainWindow *Window
bindings *binding.Bindings bindings *binding.Bindings
dispatcher frontend.Dispatcher dispatcher frontend.Dispatcher
servingFromDisk bool
} }
func init() { func init() {
@ -65,23 +65,12 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.
bindings: appBindings, bindings: appBindings,
dispatcher: dispatcher, dispatcher: dispatcher,
ctx: ctx, ctx: ctx,
startURL: "wails://wails/",
} }
result.startURL, _ = url.Parse("wails://wails/")
_starturl, _ := ctx.Value("starturl").(string) if _starturl, _ := ctx.Value("starturl").(*url.URL); _starturl != nil {
if _starturl != "" {
result.startURL = _starturl result.startURL = _starturl
} else { } 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() bindingsJSON, err := appBindings.ToJSON()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -93,8 +82,11 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.
} }
result.assets = assets result.assets = assets
// Start 10 processors to handle requests in parallel
for i := 0; i < 10; i++ {
go result.startRequestProcessor() go result.startRequestProcessor()
} }
}
go result.startMessageProcessor() 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 return nil
} }
@ -300,11 +292,15 @@ var requestBuffer = make(chan unsafe.Pointer, 100)
func (f *Frontend) startRequestProcessor() { func (f *Frontend) startRequestProcessor() {
for request := range requestBuffer { for request := range requestBuffer {
f.processRequest(request) f.processRequest(request)
C.g_object_unref(C.gpointer(request))
} }
} }
//export processURLRequest //export processURLRequest
func processURLRequest(request unsafe.Pointer) { 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 requestBuffer <- request
} }
@ -313,38 +309,29 @@ func (f *Frontend) processRequest(request unsafe.Pointer) {
uri := C.webkit_uri_scheme_request_get_uri(req) uri := C.webkit_uri_scheme_request_get_uri(req)
goURI := C.GoString(uri) goURI := C.GoString(uri)
res, err := common.ProcessRequest(goURI, f.assets, "wails", "wails") // 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()
f.assets.ProcessHTTPRequest(
goURI,
rw,
func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, goURI, nil)
if err != nil { if err != nil {
f.logger.Error("Error processing request '%s': %s (HttpResponse=%s)", goURI, err, res) return nil, err
} }
if code := res.StatusCode; code != http.StatusOK { if req.URL.Host != f.startURL.Host {
message := C.CString(res.StatusText()) if req.Body != nil {
gerr := C.g_error_new_literal(C.g_quark_from_string(message), C.int(code), message) req.Body.Close()
C.webkit_uri_scheme_request_finish_error(req, gerr)
C.g_error_free(gerr)
C.free(unsafe.Pointer(message))
return
} }
var cContent unsafe.Pointer return nil, fmt.Errorf("Expected host '%d' in request, but was '%s'", f.startURL.Host, req.URL.Host)
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)
}
} }
cMimeType := C.CString(res.MimeType) return req, nil
defer C.free(unsafe.Pointer(cMimeType)) })
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))
} }

View File

@ -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
}

View File

@ -7,7 +7,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http"
"net/http/httptest"
"net/url"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@ -16,7 +20,6 @@ import (
"github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/binding"
"github.com/wailsapp/wails/v2/internal/frontend" "github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/assetserver" "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/go-webview2/pkg/edge"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc" "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32" "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
@ -38,13 +41,12 @@ type Frontend struct {
// Assets // Assets
assets *assetserver.AssetServer assets *assetserver.AssetServer
startURL string startURL *url.URL
// main window handle // main window handle
mainWindow *Window mainWindow *Window
bindings *binding.Bindings bindings *binding.Bindings
dispatcher frontend.Dispatcher dispatcher frontend.Dispatcher
servingFromDisk bool
hasStarted bool hasStarted bool
@ -63,26 +65,17 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger.
bindings: appBindings, bindings: appBindings,
dispatcher: dispatcher, dispatcher: dispatcher,
ctx: ctx, ctx: ctx,
startURL: "http://wails.localhost/",
versionInfo: versionInfo, versionInfo: versionInfo,
} }
_starturl, _ := ctx.Value("starturl").(string) // We currently can't use wails://wails/ as other platforms do, therefore we map the assets sever onto the following url.
if _starturl != "" { result.startURL, _ = url.Parse("http://wails.localhost/")
if _starturl, _ := ctx.Value("starturl").(*url.URL); _starturl != nil {
result.startURL = _starturl result.startURL = _starturl
return result 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() bindingsJSON, err := appBindings.ToJSON()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -372,7 +365,7 @@ func (f *Frontend) setupChromium() {
chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateAllow) chromium.SetGlobalPermission(edge.CoreWebView2PermissionStateAllow)
chromium.AddWebResourceRequestedFilter("*", edge.COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL) chromium.AddWebResourceRequestedFilter("*", edge.COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)
chromium.Navigate(f.startURL) chromium.Navigate(f.startURL.String())
} }
type EventNotify struct { type EventNotify struct {
@ -403,34 +396,39 @@ func (f *Frontend) processRequest(req *edge.ICoreWebView2WebResourceRequest, arg
reqHeaders.Release() 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 //Get the request
uri, _ := req.GetUri() uri, _ := req.GetUri()
reqUri, err := url.ParseRequestURI(uri)
res, err := common.ProcessRequest(uri, f.assets, "http", "wails.localhost") if err != nil {
if err == common.ErrUnexpectedScheme { f.logger.Error("Unable to parse equest uri %s: %s", uri, err)
// In this case we should let the WebView2 handle the request with its default handler
return 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{} headers := []string{}
if mimeType := res.MimeType; mimeType != "" { for k, v := range rw.Header() {
headers = append(headers, "Content-Type: "+mimeType) headers = append(headers, fmt.Sprintf("%s: %s", k, strings.Join(v, ",")))
}
content := res.Body
if content != nil && f.servingFromDisk {
headers = append(headers, "Pragma: no-cache")
} }
env := f.chromium.Environment() 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 { if err != nil {
f.logger.Error("CreateWebResourceResponse Error: %s", err) f.logger.Error("CreateWebResourceResponse Error: %s", err)
return return
@ -596,3 +594,87 @@ func (f *Frontend) ShowWindow() {
func (f *Frontend) onFocus(arg *winc.Event) { func (f *Frontend) onFocus(arg *winc.Event) {
f.chromium.Focus() 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()
}

View File

@ -12,7 +12,6 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
@ -84,12 +83,16 @@ func (d *DevWebServer) Run(ctx context.Context) error {
return err 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) } waitCb := func() { d.LogDebug("Waiting for frontend DevServer '%s' to be ready", externalURL) }
if !checkPortIsOpen(externalURL.Host, time.Minute, waitCb) { if !checkPortIsOpen(externalURL.Host, time.Minute, waitCb) {
d.logger.Error("Timeout waiting for frontend DevServer") d.logger.Error("Timeout waiting for frontend DevServer")
} }
assetHandler = httputil.NewSingleHostReverseProxy(externalURL) assetHandler = newExternalDevServerAssetHandler(d.logger, externalURL, d.appoptions.AssetsHandler)
} }
// Setup internal dev server // Setup internal dev server
@ -103,7 +106,7 @@ func (d *DevWebServer) Run(ctx context.Context) error {
log.Fatal(err) 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()) assetServer.ServeHTTP(c.Response(), c.Request())
return nil return nil
}) })

View File

@ -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
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"io/fs" "io/fs"
"log" "log"
"net/http"
"runtime" "runtime"
"github.com/wailsapp/wails/v2/pkg/options/linux" "github.com/wailsapp/wails/v2/pkg/options/linux"
@ -42,6 +43,7 @@ type App struct {
AlwaysOnTop bool AlwaysOnTop bool
RGBA *RGBA RGBA *RGBA
Assets fs.FS Assets fs.FS
AssetsHandler http.Handler
Menu *menu.Menu Menu *menu.Menu
Logger logger.Logger `json:"-"` Logger logger.Logger `json:"-"`
LogLevel logger.LogLevel LogLevel logger.LogLevel

View File

@ -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`. 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 ## 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 Running `wails dev` will start the built in dev server which will start a file watcher in your project directory. By

View File

@ -30,6 +30,7 @@ func main() {
RGBA: &options.RGBA{R: 0, G: 0, B: 0, A: 255}, RGBA: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
AlwaysOnTop: false, AlwaysOnTop: false,
Assets: assets, Assets: assets,
AssetsHandler: assetsHandler,
Menu: app.applicationMenu(), Menu: app.applicationMenu(),
Logger: nil, Logger: nil,
LogLevel: logger.DEBUG, LogLevel: logger.DEBUG,
@ -221,6 +222,36 @@ Type: \*embed.FS
The frontend assets to be used by the application. Requires an `index.html` file. 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 ### Menu
Name: Menu Name: Menu
@ -316,7 +347,7 @@ Defines how the window should present itself at startup.
| --------------- | --- | --- | --- | | --------------- | --- | --- | --- |
| Fullscreen | ✅ | ✅ | ✅ | | Fullscreen | ✅ | ✅ | ✅ |
| Maximised | ✅ | ✅ | ✅ | | Maximised | ✅ | ✅ | ✅ |
| Minimised | ✅ | | ✅ | | Minimised | ✅ | | ✅ |
### Bind ### Bind

View File

@ -20,7 +20,7 @@ The project config resides in the `wails.json` file in the project directory. Th
"version": "[Project config version]", "version": "[Project config version]",
"outputfilename": "[The name of the binary]", "outputfilename": "[The name of the binary]",
"debounceMS": 100, // The default time the dev server waits to reload when it detects a vhange in assets "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]", "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. "runNonNativeBuildHooks": false, // Defines if build hooks should be run though they are defined for an OS other than the host OS.
"postBuildHooks": { "postBuildHooks": {

View File

@ -20,7 +20,7 @@ sidebar_position: 5
"version": "[项目配置版本]", "version": "[项目配置版本]",
"outputfilename": "[二进制文件的名称]", "outputfilename": "[二进制文件的名称]",
"debounceMS": 100, // 在检测到资源更改时,开发服务器等待重新加载的时间 "debounceMS": 100, // 在检测到资源更改时,开发服务器等待重新加载的时间
"devServer": "[将 wails 开发服务器绑定到的地址。默认:http://localhost:34115]", "devServer": "[将 wails 开发服务器绑定到的地址。默认localhost:34115]",
"appargs": "[在dev模式下以shell样式传递给应用程序的参数]", "appargs": "[在dev模式下以shell样式传递给应用程序的参数]",
"runNonNativeBuildHooks": false, // 定义构建钩子是否应该运行,尽管它们是为主机操作系统以外的操作系统定义的。 "runNonNativeBuildHooks": false, // 定义构建钩子是否应该运行,尽管它们是为主机操作系统以外的操作系统定义的。
"postBuildHooks": { "postBuildHooks": {