feat(原生桌面): 新增原生桌面与字体适配
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-10-17 17:28:36 +08:00
1 parent 0ab785ebc3
commit 8e31f888d3
19 files changed
+9092 -6499

No files matched your search

+685
View File
@@ -0,0 +1,685 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows && amd64
package webview
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"image/png"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
"github.com/xiaoqidun/guigo"
"github.com/xiaoqidun/ofdgo"
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
"github.com/xiaoqidun/ofdgo/internal/webui"
)
// applicationID 标识原生应用,与版本及可执行文件路径无关
const applicationID = "me.aite.ofdgo"
// Host 连接桌面系统能力和共用文档业务
type Host struct {
mu sync.Mutex
window *guigo.Window
files *fileStore
engines map[string]*webui.NativeEngine
cancels map[string]context.CancelFunc
fontOnce sync.Once
fonts []map[string]any
fontFiles map[string]string
fontError error
pendingPaths []string
}
// resourceReader 保持资源独立的读取位置,文件生命周期由窗口管理
type resourceReader struct {
*io.SectionReader
}
// Run 启动共用WebUI的原生入口
// 入参: ctx 运行上下文, paths 初始文件路径, dataDir 浏览器数据目录
// 返回: error 启动或运行错误
func Run(ctx context.Context, paths []string, dataDir string) error {
options, err := windowOptions(dataDir)
if err != nil {
return err
}
files, err := newFileStore()
if err != nil {
return err
}
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) }
}
static := http.FileServerFS(webuiassets.NativeFS)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/__native/") {
files.serve(w, r)
return
}
if strings.HasSuffix(r.URL.Path, ".wasm") || strings.HasSuffix(r.URL.Path, "wasm_exec.js") || strings.HasSuffix(r.URL.Path, "ofdgo_work.js") {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
static.ServeHTTP(w, r)
})
options.Handler = handler
options.ResourceHandler = h.resource
options.Bindings = bindings
options.OnReady = func(window *guigo.Window) {
h.mu.Lock()
h.window = window
h.mu.Unlock()
}
options.OnClose = func() {
h.mu.Lock()
for _, cancel := range h.cancels {
cancel()
}
h.mu.Unlock()
}
options.OnDrop = h.openPaths
options.OnError = func(err error) {
h.emit("ofdgo.error", err.Error())
}
return guigo.Run(ctx, options)
}
// Close 结束本次资源读取
func (r *resourceReader) Close() error { return nil }
// resource 将大文件直接交给宿主资源流
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") != "" {
return nil, nil
}
id := strings.TrimPrefix(r.URL.Path, "/__native/data/")
reader, size, err := h.files.resolve(id)
if err != nil {
return nil, nil
}
header := make([]byte, min(size, 512))
_, 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
}
// emit 发送当前窗口事件
func (h *Host) emit(name string, value any) {
h.mu.Lock()
w := h.window
h.mu.Unlock()
if w != nil {
_ = w.Emit(name, value)
}
}
// openPaths 将操作系统提供的文件登记后交给界面
func (h *Host) openPaths(names []string) {
files := make([]any, 0, len(names))
for _, name := range names {
handle, err := h.files.add(name, false)
if err != nil {
h.emit("ofdgo.error", err.Error())
continue
}
file, err := h.files.file(handle["nativeHandle"].(string))
if err == nil {
files = append(files, file)
}
}
h.emit("ofdgo.files", files)
}
// 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
}
text := func(key string) string { value, _ := args[key].(string); return value }
flag := func(key string) bool { value, _ := args[key].(bool); return value }
switch name {
case "engine.open":
engine := webui.NewNativeEngine(h.files.resolve)
engine.OpenWriter = h.openWriter
engine.ReserveOutput = h.reserveOutput
engine.CleanupOutput = h.cleanupOutput
engine.LoadFont = h.loadFont
id := token()
h.mu.Lock()
h.engines[id] = engine
h.mu.Unlock()
return map[string]any{"engine": 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
case "engine.call":
id := text("engine")
key := id + "/" + fmt.Sprint(args["id"])
operation, cancel := context.WithCancel(ctx)
defer cancel()
h.mu.Lock()
engine := h.engines[id]
if _, exists := h.cancels[key]; exists {
h.mu.Unlock()
return nil, fmt.Errorf("请求重复")
}
h.cancels[key] = cancel
h.mu.Unlock()
defer func() { h.mu.Lock(); delete(h.cancels, key); h.mu.Unlock() }()
if engine == nil {
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
case "open", "save", "directory":
h.mu.Lock()
window := h.window
h.mu.Unlock()
if window == nil {
return nil, fmt.Errorf("窗口尚未就绪")
}
options := guigo.DialogOptions{DefaultName: text("suggestedName"), Filters: dialogFilters(args)}
if name == "open" {
var names []string
var err error
if flag("directory") {
var directory string
directory, err = window.OpenDirectory(options)
if err == nil {
err = filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
if entry.Type().IsRegular() {
names = append(names, path)
}
return nil
})
}
} else if flag("multiple") {
names, err = window.OpenFiles(options)
} else {
var file string
file, err = window.OpenFile(options)
if err == nil {
names = []string{file}
}
}
if err != nil {
return nil, dialogError(err)
}
result := make([]any, 0, len(names))
for _, file := range names {
handle, err := h.files.add(file, false)
if err != nil {
return nil, err
}
value, err := h.files.file(handle["nativeHandle"].(string))
if err != nil {
return nil, err
}
result = append(result, value)
}
return result, nil
}
if name == "save" {
file, err := window.SaveFile(options)
if err != nil {
return nil, dialogError(err)
}
return h.files.save(file), nil
}
file, err := window.OpenDirectory(options)
if err != nil {
return nil, dialogError(err)
}
return h.files.add(file, true)
case "file":
return h.files.file(text("handle"))
case "release":
h.files.release(text("file"))
return nil, nil
case "child":
value, err := h.files.child(text("handle"), text("name"), flag("directory"), flag("create"))
if errors.Is(err, os.ErrNotExist) {
return nil, &guigo.BridgeError{Name: "NotFoundError", Message: err.Error()}
}
return value, err
case "remove":
return nil, h.files.remove(text("handle"), text("name"), flag("recursive"))
case "write.begin":
writer, err := h.files.begin(text("handle"))
if err != nil {
return nil, err
}
return map[string]any{"writer": writer.id}, nil
case "write.data", "write.commit", "write.abort":
id := text("writer")
h.files.mu.Lock()
writer := h.files.writers[id]
h.files.mu.Unlock()
if writer == nil {
return nil, fmt.Errorf("写入已结束")
}
if name == "write.commit" {
return nil, writer.Close()
}
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)
return nil, err
}
key, _ := descriptor["nativeFile"].(string)
source, size, err := h.files.resolve(key)
if err != nil {
return nil, err
}
_, err = io.Copy(writer, io.NewSectionReader(source, 0, size))
return nil, err
case "fonts":
h.fontOnce.Do(h.readFonts)
return h.fonts, h.fontError
case "title":
h.mu.Lock()
window := h.window
h.mu.Unlock()
if window == nil {
return nil, nil
}
return nil, window.SetTitle(text("title"))
case "ready":
h.mu.Lock()
paths := h.pendingPaths
h.pendingPaths = nil
h.mu.Unlock()
if len(paths) != 0 {
h.openPaths(paths)
}
return nil, nil
}
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 将共用界面的文件类型映射到系统对话框
func dialogFilters(args map[string]any) []guigo.FileFilter {
patterns := func(values []string) string {
var result []string
seen := make(map[string]bool)
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if !strings.HasPrefix(value, ".") || strings.ContainsAny(value, "/\\;*?\x00") || seen[value] {
continue
}
seen[value] = true
result = append(result, "*"+value)
}
return strings.Join(result, ";")
}
var filters []guigo.FileFilter
if accept, ok := args["accept"].(string); ok {
var extensions []string
for _, value := range strings.Split(accept, ",") {
value = strings.ToLower(strings.TrimSpace(value))
if strings.HasPrefix(value, ".") {
extensions = append(extensions, value)
continue
}
switch value {
case "image/*":
extensions = append(extensions, ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".gif", ".webp", ".jb2", ".jbig2")
case "font/*":
extensions = append(extensions, ".ttf", ".otf", ".ttc", ".otc")
default:
values, err := mime.ExtensionsByType(value)
if err == nil {
sort.Strings(values)
extensions = append(extensions, values...)
}
}
}
if pattern := patterns(extensions); pattern != "" {
filters = append(filters, guigo.FileFilter{Name: "支持的文件", Pattern: pattern})
}
}
types, _ := args["types"].([]any)
for _, item := range types {
value, _ := item.(map[string]any)
accept, _ := value["accept"].(map[string]any)
var extensions []string
var names []string
for name := range accept {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
values := accept[name]
list, _ := values.([]any)
for _, extension := range list {
if text, ok := extension.(string); ok {
extensions = append(extensions, text)
}
}
}
if pattern := patterns(extensions); pattern != "" {
name, _ := value["description"].(string)
if name == "" {
name = pattern
}
filters = append(filters, guigo.FileFilter{Name: name, Pattern: pattern})
}
}
return append(filters, guigo.FileFilter{Name: "所有文件", Pattern: "*.*"})
}
// dialogError 将用户取消与业务错误区分
func dialogError(err error) error {
if errors.Is(err, guigo.ErrCanceled) {
return &guigo.BridgeError{Name: "AbortError", Message: "已取消"}
}
return err
}
// encode 将二进制输出转为窗口内资源
func (h *Host) encode(value any) any {
switch v := value.(type) {
case []byte:
return h.files.bytes(v)
case map[string]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[key] = h.encode(item)
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = h.encode(item)
}
return out
default:
return value
}
}
// openWriter 将授权目标转换为原生事务写入器
func (h *Host) openWriter(ctx context.Context, value any, name string) (io.WriteCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
handle, _ := value.(map[string]any)
id, _ := handle["nativeHandle"].(string)
h.files.mu.Lock()
entry := h.files.entries[id]
directory := entry != nil && entry.directory
h.files.mu.Unlock()
if directory {
parts := strings.Split(strings.ReplaceAll(name, "\\", "/"), "/")
for i, part := range parts {
child, err := h.files.child(id, part, i < len(parts)-1, true)
if err != nil {
return nil, err
}
id = child["nativeHandle"].(string)
}
h.files.mu.Lock()
owned := h.files.entries[id].owned
h.files.mu.Unlock()
if !owned {
return nil, fmt.Errorf("输出文件已存在")
}
}
return h.files.begin(id)
}
// reserveOutput 为一次转换预留独立输出文件或目录
func (h *Host) reserveOutput(ctx context.Context, request webui.NativeOutputRequest) (webui.NativeOutputTarget, error) {
if err := ctx.Err(); err != nil {
return webui.NativeOutputTarget{}, err
}
handle, _ := request.Destination.(map[string]any)
id, _ := handle["nativeHandle"].(string)
base, name, err := h.files.reserve(id, request.Base, request.Extension, request.Paged)
return webui.NativeOutputTarget{Destination: request.Destination, Base: base, Name: name}, err
}
// cleanupOutput 回滚本次转换创建的输出入口
func (h *Host) cleanupOutput(ctx context.Context, target webui.NativeOutputTarget) error {
handle, _ := target.Destination.(map[string]any)
id, _ := handle["nativeHandle"].(string)
return h.files.remove(id, target.Name, true)
}
// readFonts 建立系统字体目录并登记按需读取资源
func (h *Host) readFonts() {
for _, name := range ofdgo.FontSystemFiles() {
data, err := os.ReadFile(name)
if err != nil {
continue
}
faces, err := (ofdgo.FontFile{Name: filepath.Base(name), Data: data}).Faces()
if err != nil {
continue
}
handle, err := h.files.add(name, false)
if err != nil {
continue
}
file, err := h.files.file(handle["nativeHandle"].(string))
if err != nil {
continue
}
for _, face := range faces {
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "file": file})
for _, alias := range []string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)} {
if alias != "" {
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
}
}
}
}
}
// loadFont 按转换请求读取单个系统字体
func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
h.fontOnce.Do(h.readFonts)
parts := strings.Split(h.fontFiles[name], "\x00")
if len(parts) != 2 {
return nil, fmt.Errorf("系统字体不可用")
}
data, err := os.ReadFile(parts[0])
if err != nil {
return nil, err
}
index, _ := strconv.Atoi(parts[1])
return (ofdgo.FontFile{Data: data}).Face(index)
}
// close 取消任务并释放当前窗口持有的会话
func (h *Host) close() {
h.mu.Lock()
for _, cancel := range h.cancels {
cancel()
}
engines := h.engines
h.engines = make(map[string]*webui.NativeEngine)
h.mu.Unlock()
for _, engine := range engines {
_ = engine.Close()
}
_ = h.files.close()
}
// windowOptions 创建原生窗口配置,默认沿用既有数据目录
// 入参: dataDir 自定义浏览器数据目录
// 返回: guigo.Options 窗口配置, error 配置错误
func windowOptions(dataDir string) (guigo.Options, error) {
if dataDir == "" {
cache, err := os.UserCacheDir()
if err != nil {
return guigo.Options{}, err
}
dataDir = filepath.Join(cache, "OFDGo", "webview")
}
dataDir, err := filepath.Abs(dataDir)
if err != nil {
return guigo.Options{}, err
}
icon, err := windowIcon()
if err != nil {
return guigo.Options{}, err
}
return guigo.Options{
AppID: applicationID,
Title: "OFDGo",
Icon: icon,
Width: 1440,
Height: 900,
MinWidth: 360,
MinHeight: 240,
UserDataDir: dataDir,
}, nil
}
// windowIcon 将共用SVG图标转换为透明PNG,不生成外置文件
// 返回: []byte PNG图标, error 转换错误
func windowIcon() ([]byte, error) {
data, err := webuiassets.NativeFS.ReadFile("ofdgo.svg")
if err != nil {
return nil, err
}
graphic, err := canvas.ParseSVG(bytes.NewReader(data))
if err != nil {
return nil, err
}
image := rasterizer.Draw(graphic, canvas.DPMM(512/graphic.W), canvas.DefaultColorSpace)
var buffer bytes.Buffer
if err := png.Encode(&buffer, image); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
+486
View File
@@ -0,0 +1,486 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package webview
import (
"bytes"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// fileEntry 保存窗口授权的文件或目录
type fileEntry struct {
path string
name string
data []byte
file *os.File
directory bool
owned bool
}
// fileStore 管理窗口范围内的文件授权和临时数据
type fileStore struct {
mu sync.Mutex
entries map[string]*fileEntry
writers map[string]*fileWriter
temporary string
closed bool
}
// fileWriter 在同目录临时文件中保存输出
type fileWriter struct {
id string
mu sync.Mutex
store *fileStore
file *os.File
target string
err error
closed bool
}
// newFileStore 创建独立文件授权表
func newFileStore() (*fileStore, error) {
dir, err := os.MkdirTemp("", "ofdgo-webview-")
if err != nil {
return nil, err
}
return &fileStore{entries: make(map[string]*fileEntry), writers: make(map[string]*fileWriter), temporary: dir}, nil
}
// token 生成不暴露本机路径的资源标识
func token() string { return rand.Text() }
// add 登记已有授权文件
func (s *fileStore) add(name string, directory bool) (map[string]any, error) {
info, err := os.Stat(name)
if err != nil {
return nil, err
}
if info.IsDir() != directory {
return nil, fmt.Errorf("文件类型不匹配")
}
s.mu.Lock()
defer s.mu.Unlock()
id := token()
s.entries[id] = &fileEntry{path: name, name: filepath.Base(name), directory: directory}
return s.descriptor(id), nil
}
// save 登记用户选择的输出路径
func (s *fileStore) save(name string) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
id := token()
s.entries[id] = &fileEntry{path: name, name: filepath.Base(name)}
return s.descriptor(id)
}
// descriptor 返回文件句柄,不暴露本机路径
func (s *fileStore) descriptor(id string) map[string]any {
entry := s.entries[id]
kind := "file"
if entry.directory {
kind = "directory"
}
return map[string]any{"nativeHandle": id, "name": entry.name, "kind": kind}
}
// file 创建独立读取快照并返回资源描述
func (s *fileStore) file(id string) (map[string]any, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.directory {
return nil, fmt.Errorf("文件授权无效")
}
size := int64(len(entry.data))
if entry.data == nil {
file, err := openReadFile(entry.path)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
size = info.Size()
id = token()
entry = &fileEntry{path: entry.path, name: entry.name, file: file}
s.entries[id] = entry
}
return map[string]any{"nativeFile": id, "name": entry.name, "size": size, "url": "/__native/data/" + id}, nil
}
// resolve 按授权标识读取文件并保持随机读取句柄
func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.directory {
return nil, 0, fmt.Errorf("文件授权无效")
}
if entry.data != nil {
return bytes.NewReader(entry.data), int64(len(entry.data)), nil
}
if entry.file == nil {
file, err := openReadFile(entry.path)
if err != nil {
return nil, 0, err
}
entry.file = file
}
info, err := entry.file.Stat()
if err != nil {
return nil, 0, err
}
return entry.file, info.Size(), nil
}
// bytes 登记原生输出,前端通过资源通道读取
func (s *fileStore) bytes(data []byte) map[string]any {
if data == nil {
data = []byte{}
}
s.mu.Lock()
defer s.mu.Unlock()
id := token()
s.entries[id] = &fileEntry{data: data, name: "data"}
return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true}
}
// release 释放已传递完成的内存结果
func (s *fileStore) release(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if entry := s.entries[id]; entry != nil && entry.data != nil {
delete(s.entries, id)
}
}
// child 在已授权目录中查找或创建直接子项
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("文件名无效")
}
s.mu.Lock()
defer s.mu.Unlock()
parent := s.entries[id]
if parent == nil || !parent.directory {
return nil, fmt.Errorf("目录授权无效")
}
path := filepath.Join(parent.path, name)
for key, entry := range s.entries {
if entry.path == path && entry.directory == directory {
if _, err := os.Stat(path); err == nil {
return s.descriptor(key), nil
}
}
}
root, err := os.OpenRoot(parent.path)
if err != nil {
return nil, err
}
defer root.Close()
info, err := root.Stat(name)
created := false
if errors.Is(err, os.ErrNotExist) && create {
if directory {
err = root.Mkdir(name, 0755)
} else {
var file *os.File
file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err == nil {
err = file.Close()
}
}
if err != nil {
return nil, err
}
created = true
info, err = root.Stat(name)
}
if err != nil {
return nil, err
}
if info.IsDir() != directory {
return nil, fmt.Errorf("文件类型不匹配")
}
key := token()
s.entries[key] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: created}
return s.descriptor(key), nil
}
// reserve 为批量输出独占创建入口并避开已有文件
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("文件名无效")
}
s.mu.Lock()
defer s.mu.Unlock()
parent := s.entries[id]
if parent == nil || !parent.directory {
return "", "", fmt.Errorf("目录授权无效")
}
root, err := os.OpenRoot(parent.path)
if err != nil {
return "", "", err
}
defer root.Close()
for suffix := 0; ; suffix++ {
stem := base
if suffix > 0 {
stem = fmt.Sprintf("%s (%d)", base, suffix+1)
}
name := stem
if !directory {
name += "." + extension
}
if directory {
err = root.Mkdir(name, 0755)
} else {
var file *os.File
file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err == nil {
err = file.Close()
if err != nil {
root.Remove(name)
}
}
}
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", "", err
}
s.entries[token()] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: true}
return stem, name, nil
}
}
// begin 创建文件写入事务
func (s *fileStore) begin(id string) (*fileWriter, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, os.ErrClosed
}
entry := s.entries[id]
if entry == nil || entry.directory || entry.path == "" {
return nil, fmt.Errorf("输出授权无效")
}
file, err := os.CreateTemp(filepath.Dir(entry.path), ".ofdgo-*")
if err != nil {
return nil, err
}
writer := &fileWriter{id: token(), store: s, file: file, target: entry.path}
s.writers[writer.id] = writer
return writer, nil
}
// remove 清理由当前窗口创建的目录子项
func (s *fileStore) remove(id, name string, recursive bool) error {
if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") {
return fmt.Errorf("文件名无效")
}
s.mu.Lock()
defer s.mu.Unlock()
parent := s.entries[id]
if parent == nil || !parent.directory {
return fmt.Errorf("目录授权无效")
}
target := filepath.Join(parent.path, name)
owned := false
for _, entry := range s.entries {
if entry.path == target && entry.owned {
owned = true
break
}
}
if !owned {
if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("只能清理本次创建的输出")
}
root, err := os.OpenRoot(parent.path)
if err != nil {
return err
}
defer root.Close()
for key, entry := range s.entries {
if entry.path == target || recursive && strings.HasPrefix(entry.path, target+string(filepath.Separator)) {
if entry.file != nil {
entry.file.Close()
}
delete(s.entries, key)
}
}
if recursive {
return root.RemoveAll(name)
}
return root.Remove(name)
}
// Write 追加输出数据
func (w *fileWriter) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, os.ErrClosed
}
if w.err != nil {
return 0, w.err
}
n, err := w.file.Write(data)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
w.err = err
return n, err
}
// Close 同步并替换目标文件
func (w *fileWriter) Close() error {
defer w.release()
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return os.ErrClosed
}
w.closed = true
if w.err != nil {
return errors.Join(w.err, w.file.Close(), os.Remove(w.file.Name()))
}
if err := w.file.Sync(); err != nil {
w.file.Close()
os.Remove(w.file.Name())
return err
}
if err := w.file.Close(); err != nil {
os.Remove(w.file.Name())
return err
}
if err := replaceFile(w.file.Name(), w.target); err != nil {
os.Remove(w.file.Name())
return err
}
return nil
}
// Abort 删除未提交的临时输出
func (w *fileWriter) Abort() error {
defer w.release()
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
return errors.Join(w.file.Close(), os.Remove(w.file.Name()))
}
// release 移除已结束的写入事务
func (w *fileWriter) release() {
w.store.mu.Lock()
defer w.store.mu.Unlock()
delete(w.store.writers, w.id)
}
// serve 提供窗口内资源,不建立网络监听器
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 {
file, err := os.CreateTemp(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())
if err != nil {
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)
return
}
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
}
id := strings.TrimPrefix(r.URL.Path, "/__native/data/")
reader, size, err := s.resolve(id)
if err != nil {
http.NotFound(w, r)
return
}
s.mu.Lock()
entry := s.entries[id]
s.mu.Unlock()
if entry == nil {
http.NotFound(w, r)
return
}
http.ServeContent(w, r, entry.name, time.Time{}, io.NewSectionReader(reader, 0, size))
}
// close 释放授权文件与未提交输出
func (s *fileStore) close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
writers := s.writers
entries := s.entries
s.writers = make(map[string]*fileWriter)
s.entries = make(map[string]*fileEntry)
s.mu.Unlock()
var err error
for _, writer := range writers {
err = errors.Join(err, writer.Abort())
}
for _, entry := range entries {
if entry.file != nil {
err = errors.Join(err, entry.file.Close())
}
}
return errors.Join(err, os.RemoveAll(s.temporary))
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package webview
import "os"
// openReadFile 打开独立读取句柄
func openReadFile(name string) (*os.File, error) {
return os.Open(name)
}
// replaceFile 替换目标文件并保留已有读取句柄
func replaceFile(source, target string) error {
return os.Rename(source, target)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package webview
import (
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
)
// replaceFileProc 保留目标文件属性并替换文件内容
var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
// openReadFile 允许原子替换路径,同时保持已有读取句柄有效
func openReadFile(name string) (*os.File, error) {
path, err := nativeFilePath(name)
if err != nil {
return nil, &os.PathError{Op: "open", Path: name, Err: err}
}
handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: name, Err: err}
}
return os.NewFile(uintptr(handle), name), nil
}
// replaceFile 替换已有文件,目标不存在时直接重命名
func replaceFile(source, target string) error {
from, err := nativeFilePath(source)
if err != nil {
return err
}
to, err := nativeFilePath(target)
if err != nil {
return err
}
result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0)
if result != 0 {
return nil
}
if err == syscall.ERROR_FILE_NOT_FOUND {
return os.Rename(source, target)
}
return &os.LinkError{Op: "replace", Old: source, New: target, Err: err}
}
// nativeFilePath 转换绝对路径并支持长路径与网络共享
func nativeFilePath(name string) (*uint16, error) {
path, err := filepath.Abs(name)
if err != nil {
return nil, err
}
if !strings.HasPrefix(path, `\\?\`) {
if strings.HasPrefix(path, `\\`) {
path = `\\?\UNC\` + path[2:]
} else {
path = `\\?\` + path
}
}
return syscall.UTF16PtrFromString(path)
}