diff --git a/v2/internal/frontend/desktop/darwin/Application.h b/v2/internal/frontend/desktop/darwin/Application.h index 94859f770..6c4710a06 100644 --- a/v2/internal/frontend/desktop/darwin/Application.h +++ b/v2/internal/frontend/desktop/darwin/Application.h @@ -48,7 +48,10 @@ const bool IsFullScreen(void *ctx); const bool IsMinimised(void *ctx); const bool IsMaximised(void *ctx); -void ProcessURLResponse(void *inctx, unsigned long long requestId, int statusCode, void *headersString, int headersStringLength, void* data, int datalength); +void ProcessURLDidReceiveResponse(void *inctx, unsigned long long requestId, int statusCode, void *headersString, int headersStringLength); +bool ProcessURLDidReceiveData(void *inctx, unsigned long long requestId, void* data, int datalength); +void ProcessURLDidFinish(void *inctx, unsigned long long requestId); +int ProcessURLRequestReadBodyStream(void *inctx, unsigned long long requestId, void *buf, int bufLen); /* Dialogs */ diff --git a/v2/internal/frontend/desktop/darwin/Application.m b/v2/internal/frontend/desktop/darwin/Application.m index 6b413036c..f655490d4 100644 --- a/v2/internal/frontend/desktop/darwin/Application.m +++ b/v2/internal/frontend/desktop/darwin/Application.m @@ -52,12 +52,33 @@ WailsContext* Create(const char* title, int width, int height, int frameless, in return result; } -void ProcessURLResponse(void *inctx, unsigned long long requestId, int statusCode, void *headersString, int headersStringLength, void* data, int datalength) { +void ProcessURLDidReceiveResponse(void *inctx, unsigned long long requestId, int statusCode, void *headersString, int headersStringLength) { WailsContext *ctx = (__bridge WailsContext*) inctx; @autoreleasepool { NSData *nsHeadersJSON = [NSData dataWithBytes:headersString length:headersStringLength]; + [ctx processURLDidReceiveResponse:requestId :statusCode :nsHeadersJSON]; + } +} + +bool ProcessURLDidReceiveData(void *inctx, unsigned long long requestId, void* data, int datalength) { + WailsContext *ctx = (__bridge WailsContext*) inctx; + @autoreleasepool { NSData *nsdata = [NSData dataWithBytes:data length:datalength]; - [ctx processURLResponse:requestId :statusCode :nsHeadersJSON :nsdata]; + return [ctx processURLDidReceiveData:requestId :nsdata]; + } +} + +void ProcessURLDidFinish(void *inctx, unsigned long long requestId) { + WailsContext *ctx = (__bridge WailsContext*) inctx; + @autoreleasepool { + [ctx processURLDidFinish:requestId]; + } +} + +int ProcessURLRequestReadBodyStream(void *inctx, unsigned long long requestId, void *buf, int bufLen) { + WailsContext *ctx = (__bridge WailsContext*) inctx; + @autoreleasepool { + return [ctx processURLRequestReadBodyStream:requestId :buf :bufLen]; } } diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.h b/v2/internal/frontend/desktop/darwin/WailsContext.h index b177e4037..0619bc63d 100644 --- a/v2/internal/frontend/desktop/darwin/WailsContext.h +++ b/v2/internal/frontend/desktop/darwin/WailsContext.h @@ -89,7 +89,10 @@ - (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters; - (void) loadRequest:(NSString*)url; -- (void) processURLResponse:(unsigned long long)requestId :(int)statusCode :(NSData *)headersString :(NSData*)data; +- (void) processURLDidReceiveResponse:(unsigned long long)requestId :(int)statusCode :(NSData *)headersJSON; +- (bool) processURLDidReceiveData:(unsigned long long)requestId :(NSData *)data; +- (void) processURLDidFinish:(unsigned long long)requestId; +- (int) processURLRequestReadBodyStream:(unsigned long long)requestId :(void *)buf :(int)bufLen; - (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 c47fe12e5..996dad62c 100644 --- a/v2/internal/frontend/desktop/darwin/WailsContext.m +++ b/v2/internal/frontend/desktop/darwin/WailsContext.m @@ -15,6 +15,8 @@ #import "message.h" #import "Role.h" +typedef void (^schemeTaskCaller)(id); + @implementation WailsWindow - (BOOL)canBecomeKeyWindow @@ -107,7 +109,6 @@ [self.mouseEvent release]; [self.userContentController release]; [self.urlRequests release]; - [self.urlRequestsLock release]; [self.applicationMenu release]; [super dealloc]; } @@ -138,7 +139,6 @@ - (void) CreateWindow:(int)width :(int)height :(bool)frameless :(bool)resizable :(bool)fullscreen :(bool)fullSizeContent :(bool)hideTitleBar :(bool)titlebarAppearsTransparent :(bool)hideTitle :(bool)useToolbar :(bool)hideToolbarSeparator :(bool)webviewIsTransparent :(bool)hideWindowOnClose :(NSString*)appearance :(bool)windowIsTranslucent :(int)minWidth :(int)minHeight :(int)maxWidth :(int)maxHeight { self.urlRequestsId = 0; - self.urlRequestsLock = [NSLock new]; self.urlRequests = [NSMutableDictionary new]; NSWindowStyleMask styleMask = 0; @@ -427,37 +427,57 @@ }]; } -- (void) processURLResponse:(unsigned long long)requestId :(int)statusCode :(NSData *)headersJSON :(NSData *)data { - NSNumber *key = [NSNumber numberWithUnsignedLongLong:requestId]; - - [self.urlRequestsLock lock]; - id urlSchemeTask = self.urlRequests[key]; - [self.urlRequestsLock unlock]; - - @try { - if (urlSchemeTask == nil) { - return; - } - +- (void) processURLDidReceiveResponse:(unsigned long long)requestId :(int)statusCode :(NSData *)headersJSON { + [self processURLSchemeTaskCall:requestId :^(id urlSchemeTask) { NSDictionary *headerFields = [NSJSONSerialization JSONObjectWithData: headersJSON options: NSJSONReadingMutableContainers error: nil]; NSHTTPURLResponse *response = [[[NSHTTPURLResponse alloc] initWithURL:urlSchemeTask.request.URL statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:headerFields] autorelease]; - @try { - [urlSchemeTask didReceiveResponse:response]; - [urlSchemeTask didReceiveData:data]; - [urlSchemeTask didFinish]; - } @catch (NSException *exception) { - // This is very bad to detect a stopped schemeTask this should be implemented in a better way - // See todo in stopURLSchemeTask... - if (![exception.reason isEqualToString: @"This task has already been stopped"]) { - @throw exception; + [urlSchemeTask didReceiveResponse:response]; + }]; +} + +- (bool) processURLDidReceiveData:(unsigned long long)requestId :(NSData *)data { + return [self processURLSchemeTaskCall:requestId :^(id urlSchemeTask) { + [urlSchemeTask didReceiveData:data]; + }]; +} + +- (void) processURLDidFinish:(unsigned long long)requestId { + [self processURLSchemeTaskCall:requestId :^(id urlSchemeTask) { + [urlSchemeTask didFinish]; + }]; + + NSNumber *key = [NSNumber numberWithUnsignedLongLong:requestId]; + [self removeURLSchemeTask:key]; +} + +- (int) processURLRequestReadBodyStream:(unsigned long long)requestId :(void *)buf :(int)bufLen { + int res = 0; + int *pRes = &res; + + bool hasRequest = [self processURLSchemeTaskCall:requestId :^(id urlSchemeTask) { + NSInputStream *stream = urlSchemeTask.request.HTTPBodyStream; + if (!stream) { + *pRes = -3; + } else { + NSStreamStatus status = stream.streamStatus; + if (status == NSStreamStatusAtEnd) { + *pRes = 0; + } else if (status != NSStreamStatusOpen) { + *pRes = -4; + } else if (!stream.hasBytesAvailable) { + *pRes = 0; + } else { + *pRes = [stream read:buf maxLength:bufLen]; } } - } @finally { - [self.urlRequestsLock lock]; - [self.urlRequests removeObjectForKey:key]; // This will release the urlSchemeTask which was retained from the dictionary - [self.urlRequestsLock unlock]; + }]; + + if (!hasRequest) { + res = -2; } + + return res; } - (void)webView:(nonnull WKWebView *)webView startURLSchemeTask:(nonnull id)urlSchemeTask { @@ -467,6 +487,7 @@ const char *headerJSON = ""; const void *body = nil; int bodyLen = 0; + int hasBodyStream = 0; NSData *headers = [NSJSONSerialization dataWithJSONObject: urlSchemeTask.request.allHTTPHeaderFields options:0 error: nil]; if (headers) { @@ -477,23 +498,85 @@ if (urlSchemeTask.request.HTTPBody) { body = urlSchemeTask.request.HTTPBody.bytes; bodyLen = urlSchemeTask.request.HTTPBody.length; - } else { - // TODO handle HTTPBodyStream + } else if (urlSchemeTask.request.HTTPBodyStream) { + hasBodyStream = 1; + [urlSchemeTask.request.HTTPBodyStream open]; } - [self.urlRequestsLock lock]; - self.urlRequestsId++; - unsigned long long requestId = self.urlRequestsId; - NSNumber *key = [NSNumber numberWithUnsignedLongLong:requestId]; - self.urlRequests[key] = urlSchemeTask; - [self.urlRequestsLock unlock]; + unsigned long long requestId; + @synchronized(self.urlRequests) { + self.urlRequestsId++; + requestId = self.urlRequestsId; + NSNumber *key = [NSNumber numberWithUnsignedLongLong:requestId]; + self.urlRequests[key] = urlSchemeTask; + } - processURLRequest(self, requestId, url, method, headerJSON, body, bodyLen); + processURLRequest(self, requestId, url, method, headerJSON, body, bodyLen, hasBodyStream); } - (void)webView:(nonnull WKWebView *)webView stopURLSchemeTask:(nonnull id)urlSchemeTask { - // TODO implement the stopping process here in a better way... - // As soon as we introduce response body streaming we need to rewrite this nevertheless. + NSArray *keys; + @synchronized(self.urlRequests) { + keys = [self.urlRequests allKeys]; + } + + for (NSNumber *key in keys) { + if (self.urlRequests[key] == urlSchemeTask) { + [self removeURLSchemeTask:key]; + } + } +} + +- (void) removeURLSchemeTask:(NSNumber *)urlSchemeTaskKey { + id urlSchemeTask = nil; + @synchronized(self.urlRequests) { + urlSchemeTask = self.urlRequests[urlSchemeTaskKey]; + } + + if (!urlSchemeTask) { + return; + } + + NSInputStream *stream = urlSchemeTask.request.HTTPBodyStream; + if (stream) { + [stream close]; + } + + @synchronized(self.urlRequests) { + [self.urlRequests removeObjectForKey:urlSchemeTaskKey]; + } +} + +- (bool)processURLSchemeTaskCall:(unsigned long long)requestId :(schemeTaskCaller)fn { + NSNumber *key = [NSNumber numberWithUnsignedLongLong:requestId]; + + id urlSchemeTask; + @synchronized(self.urlRequests) { + urlSchemeTask = self.urlRequests[key]; + } + + if (urlSchemeTask == nil) { + // Stopped task, drop content... + return false; + } + + @try { + fn(urlSchemeTask); + } @catch (NSException *exception) { + [self removeURLSchemeTask:key]; + + // This is very bad to detect a stopped schemeTask this should be implemented in a better way + // But it seems to be very tricky to not deadlock when keeping a lock curing executing fn() + // It seems like those call switch the thread back to the main thread and then deadlocks when they reentrant want + // to get the lock again to start another request or stop it. + if ([exception.reason isEqualToString: @"This task has already been stopped"]) { + return false; + } + + @throw exception; + } + + return true; } - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { diff --git a/v2/internal/frontend/desktop/darwin/frontend.go b/v2/internal/frontend/desktop/darwin/frontend.go index a1eff8e8d..671145702 100644 --- a/v2/internal/frontend/desktop/darwin/frontend.go +++ b/v2/internal/frontend/desktop/darwin/frontend.go @@ -14,18 +14,14 @@ 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" @@ -37,7 +33,7 @@ import ( const startURL = "wails://wails/" var messageBuffer = make(chan string, 100) -var requestBuffer = make(chan *request, 100) +var requestBuffer = make(chan *wkWebViewRequest, 100) var callbackBuffer = make(chan uint, 10) type Frontend struct { @@ -96,7 +92,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() @@ -342,8 +341,10 @@ func (f *Frontend) ExecJS(js string) { f.mainWindow.ExecJS(js) } -func (f *Frontend) processRequest(r *request) { - rw := httptest.NewRecorder() +func (f *Frontend) processRequest(r *wkWebViewRequest) { + rw := &wkWebViewResponseWriter{r: r} + defer rw.Close() + f.assets.ProcessHTTPRequest( r.url, rw, @@ -363,28 +364,6 @@ func (f *Frontend) processRequest(r *request) { 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 := rw.Body.Bytes(); _contents != nil { - content = unsafe.Pointer(&_contents[0]) - contentLen = len(_contents) - } - - var headers unsafe.Pointer - var headersLen int - if len(headerData) != 0 { - headers = unsafe.Pointer(&headerData[0]) - headersLen = len(headerData) - } - - C.ProcessURLResponse(r.ctx, r.id, C.int(rw.Code), headers, C.int(headersLen), content, C.int(contentLen)) } //func (f *Frontend) processSystemEvent(message string) { @@ -403,64 +382,12 @@ func (f *Frontend) processRequest(r *request) { // } //} -type request struct { - id C.ulonglong - url string - 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, 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) messageBuffer <- goMessage } -//export processURLRequest -func processURLRequest(ctx unsafe.Pointer, requestId C.ulonglong, url *C.char, method *C.char, headers *C.char, body unsafe.Pointer, bodyLen C.int) { - var goBody []byte - if body != nil && bodyLen != 0 { - goBody = C.GoBytes(body, bodyLen) - } - - requestBuffer <- &request{ - id: requestId, - url: C.GoString(url), - method: C.GoString(method), - headers: C.GoString(headers), - body: goBody, - ctx: ctx, - } -} - //export processCallback func processCallback(callbackID uint) { callbackBuffer <- callbackID diff --git a/v2/internal/frontend/desktop/darwin/message.h b/v2/internal/frontend/desktop/darwin/message.h index b3405f4ab..731fca37c 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*, unsigned long long, const char *, const char *, const char *, const void *, int); +void processURLRequest(void*, unsigned long long, const char *, const char *, const char *, const void *, int, int); void processMessageDialogResponse(int); void processOpenFileDialogResponse(const char*); void processSaveFileDialogResponse(const char*); diff --git a/v2/internal/frontend/desktop/darwin/wkwebview_request.go b/v2/internal/frontend/desktop/darwin/wkwebview_request.go new file mode 100644 index 000000000..ce8211970 --- /dev/null +++ b/v2/internal/frontend/desktop/darwin/wkwebview_request.go @@ -0,0 +1,106 @@ +//go:build darwin + +package darwin + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework WebKit + +#import "Application.h" +#include +*/ +import "C" +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "unsafe" +) + +//export processURLRequest +func processURLRequest(ctx unsafe.Pointer, requestId C.ulonglong, url *C.char, method *C.char, headers *C.char, body unsafe.Pointer, bodyLen C.int, hasBodyStream C.int) { + var bodyReader io.Reader + if body != nil && bodyLen != 0 { + bodyReader = bytes.NewReader(C.GoBytes(body, bodyLen)) + } else if hasBodyStream != 0 { + bodyReader = &bodyStreamReader{id: requestId, ctx: ctx} + } + + requestBuffer <- &wkWebViewRequest{ + id: requestId, + url: C.GoString(url), + method: C.GoString(method), + headers: C.GoString(headers), + body: bodyReader, + ctx: ctx, + } +} + +type wkWebViewRequest struct { + id C.ulonglong + url string + method string + headers string + body io.Reader + + ctx unsafe.Pointer +} + +func (r *wkWebViewRequest) GetHttpRequest() (*http.Request, error) { + req, err := http.NewRequest(r.method, r.url, r.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 +} + +var _ io.Reader = &bodyStreamReader{} + +type bodyStreamReader struct { + id C.ulonglong + ctx unsafe.Pointer +} + +// Read implements io.Reader +func (r *bodyStreamReader) Read(p []byte) (n int, err error) { + var content unsafe.Pointer + var contentLen int + if p != nil { + content = unsafe.Pointer(&p[0]) + contentLen = len(p) + } + + res := C.ProcessURLRequestReadBodyStream(r.ctx, r.id, content, C.int(contentLen)) + if res > 0 { + return int(res), nil + } + + switch res { + case 0: + return 0, io.EOF + case -1: + return 0, fmt.Errorf("body: stream error") + case -2: + return 0, errRequestStopped + case -3: + return 0, fmt.Errorf("body: no stream defined") + case -4: + return 0, io.ErrClosedPipe + default: + return 0, fmt.Errorf("body: unknown error %d", res) + } +} diff --git a/v2/internal/frontend/desktop/darwin/wkwebview_responsewriter.go b/v2/internal/frontend/desktop/darwin/wkwebview_responsewriter.go new file mode 100644 index 000000000..b4110ed42 --- /dev/null +++ b/v2/internal/frontend/desktop/darwin/wkwebview_responsewriter.go @@ -0,0 +1,81 @@ +//go:build darwin + +package darwin + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework WebKit + +#import "Application.h" +#include +*/ +import "C" +import ( + "encoding/json" + "errors" + "net/http" + "unsafe" +) + +var ( + errRequestStopped = errors.New("request has been stopped") +) + +type wkWebViewResponseWriter struct { + r *wkWebViewRequest + + header http.Header + wroteHeader bool +} + +func (rw *wkWebViewResponseWriter) Header() http.Header { + if rw.header == nil { + rw.header = http.Header{} + } + return rw.header +} + +func (rw *wkWebViewResponseWriter) Write(buf []byte) (int, error) { + rw.WriteHeader(http.StatusOK) + + var content unsafe.Pointer + var contentLen int + if buf != nil { + content = unsafe.Pointer(&buf[0]) + contentLen = len(buf) + } + + if !C.ProcessURLDidReceiveData(rw.r.ctx, rw.r.id, content, C.int(contentLen)) { + return 0, errRequestStopped + } + return contentLen, nil +} + +func (rw *wkWebViewResponseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + rw.wroteHeader = true + + header := map[string]string{} + for k := range rw.Header() { + header[k] = rw.Header().Get(k) + } + headerData, _ := json.Marshal(header) + + var headers unsafe.Pointer + var headersLen int + if len(headerData) != 0 { + headers = unsafe.Pointer(&headerData[0]) + headersLen = len(headerData) + } + + C.ProcessURLDidReceiveResponse(rw.r.ctx, rw.r.id, C.int(code), headers, C.int(headersLen)) +} + +func (rw *wkWebViewResponseWriter) Close() { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusNotImplemented) + } + C.ProcessURLDidFinish(rw.r.ctx, rw.r.id) +} diff --git a/website/docs/reference/options.mdx b/website/docs/reference/options.mdx index dd0d6e38c..1e7af4c19 100644 --- a/website/docs/reference/options.mdx +++ b/website/docs/reference/options.mdx @@ -258,11 +258,11 @@ Not all features of an `http.Request` are currently supported, please see the fo | DELETE | ✅ | ✅ | ✅ [^1] | | Request Headers | ✅ | ✅ | ✅ [^1] | | Request Body | ✅ | ✅ | ❌ | -| Request Body Streaming | ❌ | ❌ | ❌ | +| Request Body Streaming | ✅ | ✅ | ❌ | | Response StatusCodes | ✅ | ✅ | ✅ [^1] | | Response Headers | ✅ | ✅ | ✅ [^1] | | Response Body | ✅ | ✅ | ✅ | -| Response Body Streaming | ❌ | ❌ | ✅ | +| Response Body Streaming | ❌ | ✅ | ✅ | | WebSockets | ❌ | ❌ | ❌ | | HTTP Redirects 30x | ✅ | ❌ | ❌ | diff --git a/website/src/pages/changelog.mdx b/website/src/pages/changelog.mdx index f213150c1..03e1e6111 100644 --- a/website/src/pages/changelog.mdx +++ b/website/src/pages/changelog.mdx @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The [AssetServer](/docs/reference/options#assetserver) now supports serving the index.html file when requesting a directory. Added by @stffabi in [PR](https://github.com/wailsapp/wails/pull/2110) - Added support for WebKit2GTK 2.36+ on Linux. This brings additional features for the [AssetServer](/docs/reference/options#assetserver), like support for HTTP methods and Headers. The app must be compiled with the Go build tag `webkit2_36` to activate support for this features. This also bumps the minimum requirement of WebKit2GTK to 2.36 for your app. Fixed by @stffabi in this [PR](https://github.com/wailsapp/wails/pull/2151) - Added support for file input selection on macOS. Added by @stffabi in [PR](https://github.com/wailsapp/wails/pull/2209) +- Added support Request/Response streaming of the AssetServer on macOS. Added by @stffabi in [PR](https://github.com/wailsapp/wails/pull/2219) ### Fixed - The `noreload` flag in wails dev wasn't applied. Fixed by @stffabi in this [PR](https://github.com/wailsapp/wails/pull/2081)