From e261287baaff0a0cf857890bec0f7b96caec3339 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 17 Oct 2026 20:31:26 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8E=9F=E7=94=9F=E8=B5=84=E6=BA=90=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E4=B8=8E=E9=87=8A=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo_host.js | 46 ++++++++++--- go.mod | 2 +- go.sum | 4 +- internal/webview/webview.go | 106 ++++++++++++++++++++++++++---- internal/webview/webview_files.go | 67 +++++++++++++++---- 5 files changed, 190 insertions(+), 35 deletions(-) diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index 7a33978..bf748b5 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -58,7 +58,11 @@ class NativeHandle { async createWritable() { const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle}); return { - write: async data => this.host.call("write.data", {writer, data:await this.host.encode(data)}), + write: async data => { + const resources = new Set(); + try { return await this.host.call("write.data", {writer, data:await this.host.encode(data, "", resources)}); } + finally { for (const file of resources) await this.host.call("release", {file}).catch(() => {}); } + }, close: () => this.host.call("write.commit", {writer}), abort: () => this.host.call("write.abort", {writer}), }; @@ -77,7 +81,7 @@ class NativeHost { return this.bridge.call(`ofdgo.host.${name}`, value); } - async encode(value, key = "") { + async encode(value, key = "", resources = null) { if (value?.nativeFile) return {nativeFile:value.nativeFile, size:value.size, name:value.name}; if (value?.nativeHandle) return {nativeHandle:value.nativeHandle, name:value.name, kind:value.kind}; if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { @@ -87,12 +91,19 @@ class NativeHost { } const response = await fetch("/__native/input", {method:"POST", body:value}); if (!response.ok) throw new Error("传递数据失败"); - return response.json(); + const file = await response.json(); + resources?.add(file.nativeFile); + return file; + } + if (Array.isArray(value)) { + const results = await Promise.allSettled(value.map(item => this.encode(item, "", resources))); + const failure = results.find(item => item.status === "rejected"); + if (failure) throw failure.reason; + return results.map(item => item.value); } - if (Array.isArray(value)) return Promise.all(value.map(item => this.encode(item))); if (value && typeof value === "object") { const result = {}; - for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key); + for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key, resources); return result; } return value; @@ -118,12 +129,23 @@ class NativeHost { return value; } + async release(value) { + if (value?.nativeFile && value.temporary) { + await this.call("release", {file:value.nativeFile}).catch(() => {}); + return; + } + if (value && typeof value === "object") { + for (const item of Object.values(value)) await this.release(item); + } + } + worker() { const host = this; let closed = false; let unsubscribe; let pending = Promise.resolve(); const canceled = new Set(); + const resources = new Set(); const emit = data => { if (!closed) worker.onmessage?.({data}); }; const ready = host.call("engine.open", {}).then(({engine}) => { if (closed) { host.call("engine.close", {engine}).catch(() => {}); return engine; } @@ -146,18 +168,26 @@ class NativeHost { const engine = await ready; if (closed) return; if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"}); - const args = await host.encode(data.args); + const args = await host.encode(data.args, "", resources); if (closed) return; if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"}); const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args}); - emit({id:data.id, ...await host.decode(result, data.name)}); + if (closed) { await host.release(result); return; } + const decoded = await host.decode(result, data.name); + if (closed) { await host.release(result); return; } + emit({id:data.id, ...decoded}); }).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => canceled.delete(data.id)); }, terminate() { if (closed) return; closed = true; unsubscribe?.(); - ready.then(engine => host.call("engine.close", {engine})).catch(() => {}); + const closing = ready.then(engine => host.call("engine.close", {engine})).catch(() => {}); + pending.finally(async () => { + await closing; + for (const file of resources) await host.call("release", {file}).catch(() => {}); + resources.clear(); + }); }, }; ready.catch(() => {}); diff --git a/go.mod b/go.mod index f15fcd6..dea94e1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-text/typesetting v0.3.5 github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 - github.com/xiaoqidun/guigo v0.0.0-20261017114758-498e989ad05f + github.com/xiaoqidun/guigo v0.0.0-20261017122947-f0c082d8bbcd github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 github.com/xiaoqidun/pdfgo v0.0.0-20261016003351-18348c283ac1 diff --git a/go.sum b/go.sum index 40c94f1..d13306b 100644 --- a/go.sum +++ b/go.sum @@ -58,8 +58,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8 github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= -github.com/xiaoqidun/guigo v0.0.0-20261017114758-498e989ad05f h1:LReZHupfHnvaOvjGMdPShKDArLMOEF5Fwm8HjfOR9n4= -github.com/xiaoqidun/guigo v0.0.0-20261017114758-498e989ad05f/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE= +github.com/xiaoqidun/guigo v0.0.0-20261017122947-f0c082d8bbcd h1:IzKVmVXKjcaClHFCou3MQmSuSq+dWIde7m4AwSmuCM0= +github.com/xiaoqidun/guigo v0.0.0-20261017122947-f0c082d8bbcd/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE= github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw= github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= diff --git a/internal/webview/webview.go b/internal/webview/webview.go index ebd3c0e..2410940 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -17,6 +17,7 @@ package webview import ( + "bytes" "context" "crypto/rand" "encoding/json" @@ -56,9 +57,10 @@ type Host struct { pendingPaths []string } -// resourceReader 保持资源独立的读取位置,文件生命周期由窗口管理 +// resourceReader 保存资源读取位置及关闭操作 type resourceReader struct { *io.SectionReader + close func() error } // Run 启动共用WebUI的原生入口 @@ -115,27 +117,105 @@ func Run(ctx context.Context, paths []string, dataDir string) error { } // Close 结束本次资源读取 -func (r *resourceReader) Close() error { return nil } +// 返回: error 关闭错误 +func (r *resourceReader) Close() error { + if r.close != nil { + return r.close() + } + return nil +} -// resource 将大文件直接交给宿主资源流 +// resource 将静态资源及文件直接交给宿主资源流 +// 入参: r 资源请求 +// 返回: *guigo.Resource 资源响应, error 读取错误 func (h *Host) resource(r *http.Request) (*guigo.Resource, error) { - if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Header.Get("Range") != "" { + if r.Method != http.MethodGet && r.Method != http.MethodHead { return nil, nil } - id := strings.TrimPrefix(r.URL.Path, "/__native/data/") - reader, size, err := h.files.resolve(id) - if err != nil { - return nil, nil + for _, name := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since", "If-Range"} { + if r.Header.Get(name) != "" { + return nil, nil + } + } + var reader io.ReaderAt + var size int64 + var close func() error + transferred := false + defer func() { + if !transferred && close != nil { + close() + } + }() + if strings.HasPrefix(r.URL.Path, "/__native/data/") { + var err error + reader, size, close, err = h.files.resource(strings.TrimPrefix(r.URL.Path, "/__native/data/")) + if err != nil { + return nil, nil + } + } else { + name := strings.TrimPrefix(r.URL.Path, "/") + if name == "" { + name = "index.html" + } + data, err := webuiassets.NativeFS.ReadFile(name) + if err != nil { + return nil, nil + } + reader, size = bytes.NewReader(data), int64(len(data)) } header := make([]byte, min(size, 512)) - _, err = reader.ReadAt(header, 0) + _, err := reader.ReadAt(header, 0) if err != nil && err != io.EOF { return nil, err } - return &guigo.Resource{ - Body: &resourceReader{io.NewSectionReader(reader, 0, size)}, - Header: http.Header{"Content-Type": {http.DetectContentType(header)}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}}, - }, nil + contentType := mime.TypeByExtension(filepath.Ext(r.URL.Path)) + if contentType == "" { + contentType = http.DetectContentType(header) + } + headers := http.Header{"Content-Type": {contentType}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}, "Accept-Ranges": {"bytes"}} + start, length, status := int64(0), size, http.StatusOK + if value := r.Header.Get("Range"); value != "" && r.Method == http.MethodGet { + if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") { + return nil, nil + } + first, last, ok := strings.Cut(strings.TrimPrefix(value, "bytes="), "-") + if !ok { + return nil, nil + } + if first == "" { + count, err := strconv.ParseInt(last, 10, 64) + if err != nil || count <= 0 { + return nil, nil + } + length = min(count, size) + start = size - length + } else { + var err error + start, err = strconv.ParseInt(first, 10, 64) + if err != nil || start < 0 || start >= size { + return nil, nil + } + end := size - 1 + if last != "" { + end, err = strconv.ParseInt(last, 10, 64) + if err != nil || end < start { + return nil, nil + } + } + length = min(end, size-1) - start + 1 + } + if length == 0 { + return nil, nil + } + status = http.StatusPartialContent + headers.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, size)) + headers.Set("Content-Length", strconv.FormatInt(length, 10)) + } + if r.Method == http.MethodHead { + length = 0 + } + transferred = true + return &guigo.Resource{Body: &resourceReader{SectionReader: io.NewSectionReader(reader, start, length), close: close}, StatusCode: status, Header: headers}, nil } // emit 发送当前窗口事件 diff --git a/internal/webview/webview_files.go b/internal/webview/webview_files.go index 29d9e9c..f6d3e6d 100644 --- a/internal/webview/webview_files.go +++ b/internal/webview/webview_files.go @@ -39,11 +39,13 @@ type fileEntry struct { file *os.File directory bool owned bool + temporary bool } // fileStore 管理窗口范围内的文件授权和临时数据 type fileStore struct { mu sync.Mutex + transfers sync.WaitGroup entries map[string]*fileEntry writers map[string]*fileWriter temporary string @@ -206,12 +208,43 @@ func (s *fileStore) bytes(data []byte) map[string]any { return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true} } +// resource 创建独立资源读取句柄 +// 入参: id 文件标识 +// 返回: io.ReaderAt 随机读取器, int64 文件大小, func() error 关闭函数, error 读取错误 +func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error) { + s.mu.Lock() + defer s.mu.Unlock() + entry := s.entries[id] + if entry == nil || entry.directory { + return nil, 0, nil, os.ErrNotExist + } + if entry.data != nil { + return bytes.NewReader(entry.data), int64(len(entry.data)), nil, nil + } + file, err := openReadFile(entry.path) + if err != nil { + return nil, 0, nil, err + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, 0, nil, err + } + return file, info.Size(), file.Close, nil +} + // release 释放已传递完成的内存结果 func (s *fileStore) release(id string) { s.mu.Lock() defer s.mu.Unlock() - if entry := s.entries[id]; entry != nil && entry.data != nil { + if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary) { delete(s.entries, id) + if entry.file != nil { + entry.file.Close() + } + if entry.temporary { + os.Remove(entry.path) + } } } @@ -449,28 +482,39 @@ func (w *fileWriter) release() { func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.URL.Path == "/__native/input" && r.Method == http.MethodPost { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + http.Error(w, os.ErrClosed.Error(), 500) + return + } + s.transfers.Add(1) + s.mu.Unlock() + defer s.transfers.Done() file, err := temporaryFile(s.temporary, "input-") if err != nil { http.Error(w, err.Error(), 500) return } - _, err = io.Copy(file, r.Body) - err = errors.Join(err, file.Close()) + size, err := io.Copy(file, r.Body) if err != nil { + file.Close() os.Remove(file.Name()) http.Error(w, err.Error(), 500) return } - handle, err := s.add(file.Name(), false) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - value, err := s.file(handle["nativeHandle"].(string)) - if err != nil { - http.Error(w, err.Error(), 500) + s.mu.Lock() + if s.closed { + s.mu.Unlock() + file.Close() + os.Remove(file.Name()) + http.Error(w, os.ErrClosed.Error(), 500) return } + id := rand.Text() + s.entries[id] = &fileEntry{path: file.Name(), name: "data", file: file, temporary: true} + s.mu.Unlock() + value := map[string]any{"nativeFile": id, "size": size, "name": "data", "url": "/__native/data/" + id, "temporary": true} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(value) return @@ -508,6 +552,7 @@ func (s *fileStore) close() error { s.writers = make(map[string]*fileWriter) s.entries = make(map[string]*fileEntry) s.mu.Unlock() + s.transfers.Wait() var err error for _, writer := range writers { err = errors.Join(err, writer.Abort())