diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index bf748b5..bcd8932 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -22,12 +22,16 @@ class NativeFile { if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile); } - async arrayBuffer() { - const response = await fetch(this.url); + async arrayBuffer(signal) { + const response = await fetch(this.url, {signal}); if (!response.ok) throw new Error("读取文件失败"); return response.arrayBuffer(); } + toJSON() { + return {nativeFile:this.nativeFile, size:this.size, name:this.name}; + } + async text() { return new TextDecoder().decode(await this.arrayBuffer()); } @@ -39,6 +43,10 @@ class NativeHandle { Object.assign(this, value); } + toJSON() { + return {nativeHandle:this.nativeHandle, name:this.name, kind:this.kind}; + } + async getFile() { return new NativeFile(await this.host.call("file", {handle:this.nativeHandle})); } @@ -58,11 +66,7 @@ class NativeHandle { async createWritable() { const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle}); return { - 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(() => {}); } - }, + write: data => this.host.call("write.data", {writer, data}), close: () => this.host.call("write.commit", {writer}), abort: () => this.host.call("write.abort", {writer}), }; @@ -77,59 +81,34 @@ class NativeHost { }) : null; } - call(name, value) { - return this.bridge.call(`ofdgo.host.${name}`, value); + call(name, value, signal) { + return this.bridge.call(`ofdgo.host.${name}`, value, {signal}); } - 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)) { - if (!(value instanceof Blob)) { - const bytes = ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value); - if (bytes.byteLength <= 65536 || ["password", "key", "keyPassword", "certificate"].includes(key)) return {nativeBytes:Array.from(bytes)}; - } - const response = await fetch("/__native/input", {method:"POST", body:value}); - if (!response.ok) throw new Error("传递数据失败"); - 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 (value && typeof value === "object") { - const result = {}; - for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key, resources); - return result; - } - return value; - } - - async decode(value, name = "", key = "") { + async decode(value, name = "", key = "", signal) { if (value?.nativeFile) { const file = new NativeFile(value); if (key === "file" || name === "ofdgoConvertPDF" && key === "bytes") return file; try { - const bytes = await file.arrayBuffer(); + const bytes = await file.arrayBuffer(signal); return key === "blob" ? new Blob([bytes], {type:value.type}) : new Uint8Array(bytes); } finally { await this.call("release", {file:value.nativeFile}); } } - if (Array.isArray(value)) return Promise.all(value.map(item => this.decode(item, name))); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) value[i] = await this.decode(value[i], name, "", signal); + return value; + } if (value && typeof value === "object") { - const result = {}; - for (const [field, item] of Object.entries(value)) result[field] = await this.decode(item, name, field); - return result; + for (const [field, item] of Object.entries(value)) value[field] = await this.decode(item, name, field, signal); + return value; } return value; } async release(value) { + if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return; if (value?.nativeFile && value.temporary) { await this.call("release", {file:value.nativeFile}).catch(() => {}); return; @@ -144,11 +123,10 @@ class NativeHost { let closed = false; let unsubscribe; let pending = Promise.resolve(); - const canceled = new Set(); - const resources = new Set(); + const requests = new Map(); 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; } + if (closed) return engine; unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit); emit({type:"ready"}); return engine; @@ -159,35 +137,35 @@ class NativeHost { postMessage(data) { if (closed) return; if (data.type === "cancel") { - canceled.add(data.id); - ready.then(engine => host.call("engine.cancel", {engine, id:data.id})).catch(() => {}); + requests.get(data.id)?.abort(); return; } if (data.type === "font") return; + const controller = new AbortController(); + requests.set(data.id, controller); pending = pending.then(async () => { 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, "", 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}); + controller.signal.throwIfAborted(); + const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args:data.args}, controller.signal); if (closed) { await host.release(result); return; } - const decoded = await host.decode(result, data.name); + let decoded; + try { + controller.signal.throwIfAborted(); + decoded = await host.decode(result, data.name, "", controller.signal); + controller.signal.throwIfAborted(); + } + catch (error) { await host.release(result); throw error; } 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)); + }).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => requests.delete(data.id)); }, terminate() { if (closed) return; closed = true; unsubscribe?.(); - 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(); - }); + for (const controller of requests.values()) controller.abort(); + ready.then(engine => host.call("engine.close", {engine})).catch(() => {}); }, }; ready.catch(() => {}); diff --git a/go.mod b/go.mod index be6b629..efca925 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-20261017125337-3ba9ee2327d9 + github.com/xiaoqidun/guigo v0.0.0-20261017140706-fa172a358fcb 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 807f6a3..7d74a41 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-20261017125337-3ba9ee2327d9 h1:nEvRfs+yvtx6nJIjsPNhCF6sVmgmNYLKV7ln5cwR2fM= -github.com/xiaoqidun/guigo v0.0.0-20261017125337-3ba9ee2327d9/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE= +github.com/xiaoqidun/guigo v0.0.0-20261017140706-fa172a358fcb h1:6P+29TxUu6JJX1OqEm/rlCwQ9AjGsWPBsKLNyw/sHUI= +github.com/xiaoqidun/guigo v0.0.0-20261017140706-fa172a358fcb/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/webui/webui_native.go b/internal/webui/webui_native.go index eb4e635..cb35356 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -40,6 +40,8 @@ const ( ) // NativeResolver 将授权标识解析为随机读取源,生命周期由宿主管理 +// 入参: token 资源授权标识 +// 返回: io.ReaderAt 随机读取器, int64 资源大小, error 解析错误 type NativeResolver func(token string) (io.ReaderAt, int64, error) // NativeOutputRequest 请求为整次批量转换预留唯一输出入口 @@ -60,7 +62,9 @@ type NativeOutputTarget struct { // hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭 type NativeEngine struct { businessEngine - Resolve NativeResolver + // Resolve 按授权标识提供资源读取源 + Resolve NativeResolver + // LoadFont 按名称读取系统字体数据 LoadFont func(context.Context, string) ([]byte, error) // OpenWriter 必须防止覆盖已有文件,成功时Close提交,失败时仅调用可选Abort // 不支持Abort的输出由宿主负责回收,宿主也负责回滚已完成的批量输出 @@ -258,30 +262,33 @@ func (r nativeReader) ReadAt(data []byte, offset int64) (int, error) { return total, nil } -// readBytes 读取需要完整数据的资源,每块检查取消 +// readBytes 分块读取完整资源并检查取消状态 +// 入参: ctx 调用上下文, value 资源参数 +// 返回: []byte 资源数据, error 读取错误 func (e *NativeEngine) readBytes(ctx context.Context, value any) ([]byte, error) { source, size, err := e.source(ctx, value) if err != nil { return nil, err } - var output bytes.Buffer - chunk := make([]byte, 256<<10) + if size > int64(int(^uint(0)>>1)) { + return nil, fmt.Errorf("resource exceeds address space") + } + output := make([]byte, int(size)) for offset := int64(0); offset < size; { if err := ctx.Err(); err != nil { return nil, err } - n := int(min(int64(len(chunk)), size-offset)) - got, err := source.ReadAt(chunk[:n], offset) + n := int(min(int64(256<<10), size-offset)) + got, err := source.ReadAt(output[int(offset):int(offset)+n], offset) if got != n { return nil, io.ErrUnexpectedEOF } if err != nil && err != io.EOF { return nil, err } - output.Write(chunk[:got]) offset += int64(got) } - return output.Bytes(), nil + return output, nil } // nativePlain 保留字节并转换JSON结构 diff --git a/internal/webview/webview.go b/internal/webview/webview.go index 2410940..e0f17ad 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "crypto/rand" - "encoding/json" "errors" "fmt" "io" @@ -49,6 +48,7 @@ type Host struct { window *guigo.Window files *fileStore engines map[string]*webui.NativeEngine + engineStops map[string]func() bool cancels map[string]context.CancelFunc fontOnce sync.Once fonts []map[string]any @@ -78,8 +78,8 @@ func Run(ctx context.Context, paths []string, dataDir string) error { h := &Host{files: files, engines: make(map[string]*webui.NativeEngine), cancels: make(map[string]context.CancelFunc), fontFiles: make(map[string]string), pendingPaths: append([]string(nil), paths...)} defer h.close() bindings := make(map[string]guigo.HandlerFunc) - for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.cancel", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} { - bindings["ofdgo.host."+name] = func(ctx context.Context, raw json.RawMessage) (any, error) { return h.call(ctx, name, raw) } + for _, name := range []string{"engine.open", "engine.close", "engine.call", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} { + bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) } } static := http.FileServerFS(webuiassets.NativeFS) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -219,6 +219,7 @@ func (h *Host) resource(r *http.Request) (*guigo.Resource, error) { } // emit 发送当前窗口事件 +// 入参: name 事件名称, value 事件数据 func (h *Host) emit(name string, value any) { h.mu.Lock() w := h.window @@ -229,6 +230,7 @@ func (h *Host) emit(name string, value any) { } // openPaths 将操作系统提供的文件登记后交给界面 +// 入参: names 文件路径列表 func (h *Host) openPaths(names []string) { files := make([]any, 0, len(names)) for _, name := range names { @@ -246,10 +248,12 @@ func (h *Host) openPaths(names []string) { } // call 执行宿主协议并保留业务结果结构 -func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, error) { - var args map[string]any - if err := json.Unmarshal(raw, &args); err != nil { - return nil, err +// 入参: ctx 调用上下文, name 方法名称, value 已解码参数 +// 返回: any 调用结果, error 执行错误 +func (h *Host) call(ctx context.Context, name string, value any) (any, error) { + args, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("参数无效") } text := func(key string) string { value, _ := args[key].(string); return value } flag := func(key string) bool { value, _ := args[key].(bool); return value } @@ -263,32 +267,14 @@ func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, id := rand.Text() h.mu.Lock() h.engines[id] = engine + if h.engineStops == nil { + h.engineStops = make(map[string]func() bool) + } + h.engineStops[id] = context.AfterFunc(guigo.PageContext(ctx), func() { _ = h.closeEngine(id) }) h.mu.Unlock() - return map[string]any{"engine": id}, nil + return guigo.Result{Value: map[string]any{"engine": id}, Discard: func() { _ = h.closeEngine(id) }}, nil case "engine.close": - id := text("engine") - h.mu.Lock() - engine := h.engines[id] - delete(h.engines, id) - for key, cancel := range h.cancels { - if strings.HasPrefix(key, id+"/") { - cancel() - } - } - h.mu.Unlock() - if engine != nil { - return nil, engine.Close() - } - return nil, nil - case "engine.cancel": - key := text("engine") + "/" + fmt.Sprint(args["id"]) - h.mu.Lock() - cancel := h.cancels[key] - h.mu.Unlock() - if cancel != nil { - cancel() - } - return nil, nil + return nil, h.closeEngine(text("engine")) case "engine.call": id := text("engine") key := id + "/" + fmt.Sprint(args["id"]) @@ -307,20 +293,14 @@ func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, return nil, fmt.Errorf("会话已关闭") } values, _ := args["args"].([]any) - for i, value := range values { - var err error - values[i], err = nativeBytes(value) - if err != nil { - return nil, err - } - } result := engine.Call(operation, text("name"), values, func(event any) { if value, ok := event.(map[string]any); ok { value["id"] = args["id"] h.emit("ofdgo.engine."+id, value) } }) - return h.encode(result), nil + encoded := h.encode(guigo.PageContext(ctx), result) + return guigo.Result{Value: encoded, Discard: func() { h.discard(encoded) }}, nil case "open", "save", "directory": h.mu.Lock() window := h.window @@ -420,13 +400,11 @@ func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, if name == "write.abort" { return nil, writer.Abort() } - descriptor, _ := args["data"].(map[string]any) - if value, err := nativeBytes(descriptor); err != nil { - return nil, err - } else if data, ok := value.([]byte); ok { - _, err = writer.Write(data) + if data, ok := args["data"].([]byte); ok { + _, err := writer.Write(data) return nil, err } + descriptor, _ := args["data"].(map[string]any) key, _ := descriptor["nativeFile"].(string) source, size, err := h.files.resolve(key) if err != nil { @@ -458,48 +436,9 @@ func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, return nil, fmt.Errorf("未知宿主操作") } -// nativeBytes 还原仅在消息内传递的小型二进制参数 -func nativeBytes(value any) (any, error) { - switch value := value.(type) { - case map[string]any: - if raw, exists := value["nativeBytes"]; exists { - items, ok := raw.([]any) - if !ok || len(value) != 1 || len(items) > 1<<20 { - return nil, fmt.Errorf("二进制参数无效") - } - data := make([]byte, len(items)) - for i, item := range items { - number, ok := item.(float64) - if !ok || number < 0 || number > 255 || float64(byte(number)) != number { - return nil, fmt.Errorf("二进制参数无效") - } - data[i] = byte(number) - } - return data, nil - } - for key, item := range value { - decoded, err := nativeBytes(item) - if err != nil { - return nil, err - } - value[key] = decoded - } - return value, nil - case []any: - for i, item := range value { - decoded, err := nativeBytes(item) - if err != nil { - return nil, err - } - value[i] = decoded - } - return value, nil - default: - return value, nil - } -} - // dialogFilters 将共用界面的文件类型映射到系统对话框 +// 入参: args 对话框参数 +// 返回: []guigo.FileFilter 文件类型列表 func dialogFilters(args map[string]any) []guigo.FileFilter { patterns := func(values []string) string { var result []string @@ -571,6 +510,8 @@ func dialogFilters(args map[string]any) []guigo.FileFilter { } // dialogError 将用户取消与业务错误区分 +// 入参: err 对话框错误 +// 返回: error 页面可识别的错误 func dialogError(err error) error { if errors.Is(err, guigo.ErrCanceled) { return &guigo.BridgeError{Name: "AbortError", Message: "已取消"} @@ -578,21 +519,23 @@ func dialogError(err error) error { return err } -// encode 将二进制输出转为窗口内资源 -func (h *Host) encode(value any) any { +// encode 将二进制输出登记为页面资源 +// 入参: ctx 页面上下文, value 原生调用结果 +// 返回: any 包含资源描述的调用结果 +func (h *Host) encode(ctx context.Context, value any) any { switch v := value.(type) { case []byte: - return h.files.bytes(v) + return h.files.bytes(ctx, v) case map[string]any: out := make(map[string]any, len(v)) for key, item := range v { - out[key] = h.encode(item) + out[key] = h.encode(ctx, item) } return out case []any: out := make([]any, len(v)) for i, item := range v { - out[i] = h.encode(item) + out[i] = h.encode(ctx, item) } return out default: @@ -600,7 +543,28 @@ func (h *Host) encode(value any) any { } } +// discard 释放页面未接收的临时结果 +// 入参: value 包含资源描述的调用结果 +func (h *Host) discard(value any) { + switch v := value.(type) { + case map[string]any: + if id, ok := v["nativeFile"].(string); ok && v["temporary"] == true { + h.files.release(id) + return + } + for _, item := range v { + h.discard(item) + } + case []any: + for _, item := range v { + h.discard(item) + } + } +} + // openWriter 将授权目标转换为原生事务写入器 +// 入参: ctx 调用上下文, value 目标句柄, name 输出文件名 +// 返回: io.WriteCloser 事务写入器, error 创建错误 func (h *Host) openWriter(ctx context.Context, value any, name string) (io.WriteCloser, error) { if err := ctx.Err(); err != nil { return nil, err @@ -631,6 +595,8 @@ func (h *Host) openWriter(ctx context.Context, value any, name string) (io.Write } // reserveOutput 为一次转换预留独立输出文件或目录 +// 入参: ctx 调用上下文, request 输出请求 +// 返回: webui.NativeOutputTarget 预留目标, error 创建错误 func (h *Host) reserveOutput(ctx context.Context, request webui.NativeOutputRequest) (webui.NativeOutputTarget, error) { if err := ctx.Err(); err != nil { return webui.NativeOutputTarget{}, err @@ -642,6 +608,8 @@ func (h *Host) reserveOutput(ctx context.Context, request webui.NativeOutputRequ } // cleanupOutput 回滚本次转换创建的输出入口 +// 入参: ctx 调用上下文, target 预留目标 +// 返回: error 清理错误 func (h *Host) cleanupOutput(ctx context.Context, target webui.NativeOutputTarget) error { handle, _ := target.Destination.(map[string]any) id, _ := handle["nativeHandle"].(string) @@ -679,6 +647,8 @@ func (h *Host) readFonts() { } // loadFont 按转换请求读取单个系统字体 +// 入参: ctx 调用上下文, name 字体文件路径及字体索引 +// 返回: []byte 字体数据, error 读取错误 func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err @@ -696,6 +666,29 @@ func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) { return (ofdgo.FontFile{Data: data}).Face(index) } +// closeEngine 取消会话调用并释放文档 +// 入参: id 会话标识 +// 返回: error 关闭错误 +func (h *Host) closeEngine(id string) error { + h.mu.Lock() + engine := h.engines[id] + delete(h.engines, id) + if stop := h.engineStops[id]; stop != nil { + stop() + delete(h.engineStops, id) + } + for key, cancel := range h.cancels { + if strings.HasPrefix(key, id+"/") { + cancel() + } + } + h.mu.Unlock() + if engine != nil { + return engine.Close() + } + return nil +} + // close 取消任务并释放当前窗口持有的会话 func (h *Host) close() { h.mu.Lock() @@ -703,6 +696,10 @@ func (h *Host) close() { cancel() } engines := h.engines + for _, stop := range h.engineStops { + stop() + } + h.engineStops = nil h.engines = make(map[string]*webui.NativeEngine) h.mu.Unlock() for _, engine := range engines { diff --git a/internal/webview/webview_files.go b/internal/webview/webview_files.go index f6d3e6d..afe37e3 100644 --- a/internal/webview/webview_files.go +++ b/internal/webview/webview_files.go @@ -16,8 +16,8 @@ package webview import ( "bytes" + "context" "crypto/rand" - "encoding/json" "errors" "fmt" "io" @@ -37,6 +37,7 @@ type fileEntry struct { name string data []byte file *os.File + stop func() bool directory bool owned bool temporary bool @@ -45,7 +46,6 @@ type fileEntry struct { // fileStore 管理窗口范围内的文件授权和临时数据 type fileStore struct { mu sync.Mutex - transfers sync.WaitGroup entries map[string]*fileEntry writers map[string]*fileWriter temporary string @@ -64,6 +64,7 @@ type fileWriter struct { } // newFileStore 创建独立文件授权表 +// 返回: *fileStore 文件授权表, error 创建错误 func newFileStore() (*fileStore, error) { dir, err := temporaryDirectory() if err != nil { @@ -110,6 +111,8 @@ func temporaryFile(dir, prefix string) (*os.File, error) { } // add 登记已有授权文件 +// 入参: name 文件路径, directory 是否为目录 +// 返回: map[string]any 句柄描述, error 登记错误 func (s *fileStore) add(name string, directory bool) (map[string]any, error) { info, err := os.Stat(name) if err != nil { @@ -126,6 +129,8 @@ func (s *fileStore) add(name string, directory bool) (map[string]any, error) { } // save 登记用户选择的输出路径 +// 入参: name 输出文件路径 +// 返回: map[string]any 句柄描述 func (s *fileStore) save(name string) map[string]any { s.mu.Lock() defer s.mu.Unlock() @@ -135,6 +140,8 @@ func (s *fileStore) save(name string) map[string]any { } // descriptor 返回文件句柄,不暴露本机路径 +// 入参: id 授权标识 +// 返回: map[string]any 句柄描述 func (s *fileStore) descriptor(id string) map[string]any { entry := s.entries[id] kind := "file" @@ -145,6 +152,8 @@ func (s *fileStore) descriptor(id string) map[string]any { } // file 创建独立读取快照并返回资源描述 +// 入参: id 授权标识 +// 返回: map[string]any 资源描述, error 读取错误 func (s *fileStore) file(id string) (map[string]any, error) { s.mu.Lock() defer s.mu.Unlock() @@ -172,6 +181,8 @@ func (s *fileStore) file(id string) (map[string]any, error) { } // resolve 按授权标识读取文件并保持随机读取句柄 +// 入参: id 授权标识 +// 返回: io.ReaderAt 随机读取器, int64 文件大小, error 读取错误 func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) { s.mu.Lock() defer s.mu.Unlock() @@ -196,15 +207,20 @@ func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) { return entry.file, info.Size(), nil } -// bytes 登记原生输出,前端通过资源通道读取 -func (s *fileStore) bytes(data []byte) map[string]any { +// bytes 登记原生输出,页面退出时释放未读取数据 +// 入参: ctx 页面上下文, data 输出数据 +// 返回: map[string]any 资源描述,页面取消或授权表关闭时返回nil +func (s *fileStore) bytes(ctx context.Context, data []byte) map[string]any { if data == nil { data = []byte{} } s.mu.Lock() defer s.mu.Unlock() + if s.closed || ctx.Err() != nil { + return nil + } id := rand.Text() - s.entries[id] = &fileEntry{data: data, name: "data"} + s.entries[id] = &fileEntry{data: data, name: "data", stop: context.AfterFunc(ctx, func() { s.release(id) })} return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true} } @@ -233,12 +249,16 @@ func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error return file, info.Size(), file.Close, nil } -// release 释放已传递完成的内存结果 +// release 释放内存结果或临时文件并取消页面清理回调 +// 入参: id 资源标识 func (s *fileStore) release(id string) { s.mu.Lock() defer s.mu.Unlock() if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary) { delete(s.entries, id) + if entry.stop != nil { + entry.stop() + } if entry.file != nil { entry.file.Close() } @@ -249,6 +269,8 @@ func (s *fileStore) release(id string) { } // child 在已授权目录中查找或创建直接子项 +// 入参: id 目录授权标识, name 子项名称, directory 是否为目录, create 是否允许创建 +// 返回: map[string]any 句柄描述, error 访问错误 func (s *fileStore) child(id, name string, directory, create bool) (map[string]any, error) { if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") { return nil, fmt.Errorf("文件名无效") @@ -302,6 +324,8 @@ func (s *fileStore) child(id, name string, directory, create bool) (map[string]a } // reserve 为批量输出独占创建入口并避开已有文件 +// 入参: id 目录授权标识, base 基础名称, extension 扩展名, directory 是否创建目录 +// 返回: string 输出基础名称, string 输出入口名称, error 创建错误 func (s *fileStore) reserve(id, base, extension string, directory bool) (string, string, error) { if !fs.ValidPath(base) || base == "." || strings.ContainsAny(base+extension, "/\\:") { return "", "", fmt.Errorf("文件名无效") @@ -350,6 +374,8 @@ func (s *fileStore) reserve(id, base, extension string, directory bool) (string, } // begin 创建文件写入事务 +// 入参: id 输出授权标识 +// 返回: *fileWriter 事务写入器, error 创建错误 func (s *fileStore) begin(id string) (*fileWriter, error) { s.mu.Lock() defer s.mu.Unlock() @@ -370,6 +396,8 @@ func (s *fileStore) begin(id string) (*fileWriter, error) { } // remove 清理由当前窗口创建的目录子项 +// 入参: id 目录授权标识, name 子项名称, recursive 是否递归删除 +// 返回: error 删除错误 func (s *fileStore) remove(id, name string, recursive bool) error { if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") { return fmt.Errorf("文件名无效") @@ -414,6 +442,8 @@ func (s *fileStore) remove(id, name string, recursive bool) error { } // Write 追加输出数据 +// 入参: data 输出数据 +// 返回: int 写入字节数, error 写入错误 func (w *fileWriter) Write(data []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() @@ -432,6 +462,7 @@ func (w *fileWriter) Write(data []byte) (int, error) { } // Close 同步并替换目标文件 +// 返回: error 提交错误 func (w *fileWriter) Close() error { defer w.release() w.mu.Lock() @@ -460,6 +491,7 @@ func (w *fileWriter) Close() error { } // Abort 删除未提交的临时输出 +// 返回: error 清理错误 func (w *fileWriter) Abort() error { defer w.release() w.mu.Lock() @@ -479,46 +511,9 @@ func (w *fileWriter) release() { } // serve 提供窗口内资源,不建立网络监听器 +// 入参: w 响应写入器, r 资源请求 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 - } - size, err := io.Copy(file, r.Body) - if err != nil { - file.Close() - os.Remove(file.Name()) - http.Error(w, err.Error(), 500) - return - } - 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 - } if !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Method != http.MethodGet && r.Method != http.MethodHead { http.NotFound(w, r) return @@ -540,6 +535,7 @@ func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) { } // close 释放授权文件与未提交输出 +// 返回: error 清理错误 func (s *fileStore) close() error { s.mu.Lock() if s.closed { @@ -552,12 +548,14 @@ 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()) } for _, entry := range entries { + if entry.stop != nil { + entry.stop() + } if entry.file != nil { err = errors.Join(err, entry.file.Close()) }