feat(原生桌面): 复用窗口新增标签
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-10-25 17:33:09 +08:00
1 parent 1c576bde18
commit 1de949abfc
8 files changed
+210 -18

No files matched your search

+31 -3
View File
@@ -2631,7 +2631,15 @@ async function closeWorkspace() {
async function closeApplicationWindow() {
window.removeEventListener("beforeunload", warnUnsaved);
window.removeEventListener("beforeunload", warnBatch);
await nativeHost.call("window.close", {});
let closed = false;
try {
closed = await nativeHost.call("window.close", {});
} finally {
if (!closed) {
window.addEventListener("beforeunload", warnUnsaved);
window.addEventListener("beforeunload", warnBatch);
}
}
}
function createDocumentTab(value = createDocumentState()) {
@@ -2847,19 +2855,21 @@ async function activateDocumentTab(tab, closing = false) {
if (workspace.switching) { workspace.nextTab = tab; return false; }
const focused = el.documentTitleBar.contains(document.activeElement);
workspace.switching = true;
try {
try {
if (!documentTab().busy && !await commitDocumentView()) return false;
detachDocumentView();
attachDocumentTab(tab);
if (focused) el.documentTabs.querySelector('[data-selected="true"]')?.focus({preventScroll: true});
} finally {
workspace.switching = false;
}
await mountDocumentTab(tab);
return true;
} catch (error) {
if (isForegroundDocument(tab)) showError(error, !state.doc);
return false;
} finally {
if (workspace.active === tab) workspace.switching = false;
const next = workspace.nextTab;
workspace.nextTab = null;
if (next && next !== workspace.active && !next.closed) queueMicrotask(() => activateDocumentTab(next));
@@ -5163,7 +5173,25 @@ async function boot() {
}
}
async function openNativeFiles(files) {
async function openNativeDocument(file) {
let retained = false;
try {
retained = await openOFD(file);
} catch (error) {
showError(error, !state.doc);
} finally {
if (!retained) await nativeHost.discardFiles([file]);
}
}
async function openNativeFiles(files, launch = false) {
if (launch) {
for (const file of files) {
while (workspace.closing || workspace.switching || batchElements.Panel.open || documentDialogs?.applicationOpen()) await new Promise(resolve => setTimeout(resolve, 100));
void openNativeDocument(file).catch(error => showError(error, !state.doc));
}
return;
}
let retained = [];
try {
if (workspace.closing || batchElements.Panel.open && batch.running) { setStatus("请等待当前操作完成"); return; }
+7
View File
@@ -726,6 +726,13 @@ class NativeHost {
}
async start(onFiles, onError) {
let launching = Promise.resolve();
this.bridge.on("ofdgo.launch", values => {
launching = launching.then(async () => {
try { await onFiles(values.map(value => new NativeFile(value)), true); }
finally { await this.call("launch.done", {}); }
}).catch(onError);
});
this.bridge.on("ofdgo.files", async values => {
try { await onFiles(values.map(value => new NativeFile(value))); }
catch (error) { onError(error); }
+2 -2
View File
@@ -8,12 +8,13 @@ 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-20261025075751-73659d2a5eba
github.com/xiaoqidun/guigo v0.0.0-20261025093134-18d57dc4973b
github.com/xiaoqidun/j2kgo v1.0.2
github.com/xiaoqidun/jbig2 v1.1.8
github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5
golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0
golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0
)
@@ -35,7 +36,6 @@ require (
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/yuin/goldmark v1.8.5 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/knuth v0.6.0 // indirect
modernc.org/token v1.1.0 // indirect
star-tex.org/x/tex v0.7.1 // indirect
+2 -2
View File
@@ -60,8 +60,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-20261025075751-73659d2a5eba h1:8gQ9K2aIpYl72TdPAYWDju4YDhkrc3EyqIapWJd0AO8=
github.com/xiaoqidun/guigo v0.0.0-20261025075751-73659d2a5eba/go.mod h1:bXz+dPMVntPIVwePTTuMXt8Q0z1YoUGuROQSsXyUEfY=
github.com/xiaoqidun/guigo v0.0.0-20261025093134-18d57dc4973b h1:GlNcsrDSkahmjrQdA/q5yjkM5qWQw7pOBqY1qv625tU=
github.com/xiaoqidun/guigo v0.0.0-20261025093134-18d57dc4973b/go.mod h1:bXz+dPMVntPIVwePTTuMXt8Q0z1YoUGuROQSsXyUEfY=
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=
+125 -11
View File
@@ -63,6 +63,10 @@ type Host struct {
fontFiles map[string]string
fontError error
pendingPaths []string
launchMu sync.Mutex
launchActivate bool
launchClosed bool
launchPending int
frontendReady bool
closeAllowed bool
}
@@ -93,11 +97,38 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
if err != nil {
return err
}
paths = append([]string(nil), paths...)
for index, path := range paths {
paths[index], err = filepath.Abs(path)
if err != nil {
return err
}
}
h := &Host{pendingPaths: paths}
data, err := json.Marshal(paths)
if err != nil {
return err
}
if err := os.MkdirAll(options.UserDataDir, 0700); err != nil {
return err
}
key, err := instanceDirectory(options.UserDataDir)
if err != nil {
return err
}
instance, err := guigo.OpenInstance(ctx, "OFDGo:"+key, data, h.receiveLaunch)
if err != nil || instance == nil {
return err
}
defer instance.Close()
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...)}
h.files = files
h.engines = make(map[string]*webui.NativeEngine)
h.cancels = make(map[string]context.CancelFunc)
h.fontFiles = make(map[string]string)
cacheBytes := int64(256 << 20)
if memory := conversionMemory; memory != nil {
cacheBytes = max(32<<20, memory()/8)
@@ -110,7 +141,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.conversionPool = h.resources.ConversionPool()
defer h.close()
bindings := make(map[string]guigo.HandlerFunc)
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open", "association.query", "association.register", "association.remove", "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print"} {
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "launch.done", "title", "window.info", "window.drag", "window.close", "update.check", "update.open", "association.query", "association.register", "association.remove", "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print"} {
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
}
static := http.FileServerFS(webuiassets.NativeFS)
@@ -136,6 +167,9 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.mu.Unlock()
}
options.OnClose = func() {
h.launchMu.Lock()
h.launchClosed = true
h.launchMu.Unlock()
h.mu.Lock()
for _, cancel := range h.cancels {
cancel()
@@ -278,6 +312,13 @@ func (h *Host) emit(name string, value any) {
// openPaths 将操作系统提供的文件登记后交给界面
// 入参: names 文件路径列表
func (h *Host) openPaths(names []string) {
_ = h.sendPaths(names, "ofdgo.files")
}
// sendPaths 将文件登记后发送到指定界面入口
// 入参: names 文件路径列表, event 界面事件名称
// 返回: error 投递错误
func (h *Host) sendPaths(names []string, event string) error {
files := make([]any, 0, len(names))
for _, name := range names {
file, err := h.files.addDrop(name)
@@ -290,12 +331,72 @@ func (h *Host) openPaths(names []string) {
h.mu.Lock()
w := h.window
h.mu.Unlock()
if w != nil && w.Emit("ofdgo.files", files) == nil {
return
var err error
if w == nil {
err = guigo.ErrClosed
} else {
err = w.Emit(event, files)
}
if err == nil {
return nil
}
for _, file := range files {
h.files.discardDrop(file.(map[string]any)["nativeFile"].(string))
}
return err
}
// receiveLaunch 接收已有进程转交的文件及激活请求
// 入参: ctx 请求上下文, data 文件路径消息
// 返回: error 接收错误
func (h *Host) receiveLaunch(ctx context.Context, data []byte) error {
var paths []string
if err := json.Unmarshal(data, &paths); err != nil {
return err
}
for _, path := range paths {
if !filepath.IsAbs(path) {
return errors.New("file path must be absolute")
}
}
h.launchMu.Lock()
defer h.launchMu.Unlock()
if h.launchClosed {
return guigo.ErrClosed
}
if err := ctx.Err(); err != nil {
return err
}
h.pendingPaths = append(h.pendingPaths, paths...)
h.launchActivate = true
return h.flushLaunch()
}
// flushLaunch 在界面就绪后按接收顺序投递外部打开请求,调用方持有launchMu
// 返回: error 投递错误
func (h *Host) flushLaunch() error {
h.mu.Lock()
ready, window := h.frontendReady, h.window
h.mu.Unlock()
if !ready || window == nil {
return nil
}
if h.launchActivate {
if err := window.Activate(); err != nil {
return err
}
h.launchActivate = false
}
if len(h.pendingPaths) == 0 {
return nil
}
paths := h.pendingPaths
h.pendingPaths = nil
if err := h.sendPaths(paths, "ofdgo.launch"); err != nil {
return err
}
h.launchPending++
return nil
}
// call 执行宿主协议并保留业务结果结构
@@ -603,10 +704,16 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
return window.TitleBarInfo()
}
if name == "window.close" {
h.launchMu.Lock()
defer h.launchMu.Unlock()
if h.launchPending != 0 || len(h.pendingPaths) != 0 {
return false, nil
}
h.launchClosed = true
h.mu.Lock()
h.closeAllowed = true
h.mu.Unlock()
return nil, window.Close()
return true, window.Close()
}
values, ok := args["regions"].([]any)
if !ok {
@@ -628,16 +735,20 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
regions = append(regions, guigo.Rect{X: x, Y: y, Width: width, Height: height})
}
return nil, window.SetDragRegions(regions)
case "launch.done":
h.launchMu.Lock()
if h.launchPending > 0 {
h.launchPending--
}
h.launchMu.Unlock()
return nil, nil
case "ready":
h.launchMu.Lock()
defer h.launchMu.Unlock()
h.mu.Lock()
paths := h.pendingPaths
h.pendingPaths = nil
h.frontendReady = true
h.mu.Unlock()
if len(paths) != 0 {
h.openPaths(paths)
}
return nil, nil
return nil, h.flushLaunch()
}
return nil, fmt.Errorf("未知宿主操作")
}
@@ -1019,6 +1130,9 @@ func (h *Host) closeEngine(id string) error {
// close 取消任务并释放当前窗口持有的会话
func (h *Host) close() {
h.launchMu.Lock()
h.launchClosed = true
h.launchMu.Unlock()
h.mu.Lock()
for _, cancel := range h.cancels {
cancel()
+8
View File
@@ -16,12 +16,20 @@ package webview
import (
"os"
"path/filepath"
"strings"
)
// conversionMemory 未提供系统内存探测时仅按并行数调度
var conversionMemory func() int64
// instanceDirectory 解析数据目录的实际路径,使符号链接复用同一实例
// 入参: path 数据目录路径
// 返回: string 规范路径, error 路径解析错误
func instanceDirectory(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
// fontNameLanguage 获取Linux区域设置对应的OpenType名称表标识
// 返回: uint16 名称表平台标识, uint16 显示语言标识
func fontNameLanguage() (uint16, uint16) {
+9
View File
@@ -16,9 +16,18 @@
package webview
import "path/filepath"
// conversionMemory 未提供系统内存探测时仅按并行数调度
var conversionMemory func() int64
// instanceDirectory 解析数据目录的实际路径
// 入参: path 数据目录路径
// 返回: string 规范路径, error 路径解析错误
func instanceDirectory(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
// fontNameLanguage 未指定系统显示语言时沿用字体名称表顺序
// 返回: uint16 未指定平台标识, uint16 未指定语言标识
func fontNameLanguage() (uint16, uint16) {
+26
View File
@@ -15,13 +15,39 @@
package webview
import (
"os"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// memoryStatusProc 查询系统物理内存和提交额度
var memoryStatusProc = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
// instanceDirectory 解析数据目录的实际路径,使目录联接及大小写别名复用同一实例
// 入参: path 数据目录路径
// 返回: string 规范路径, error 路径解析错误
func instanceDirectory(path string) (string, error) {
dir, err := os.Open(path)
if err != nil {
return "", err
}
defer dir.Close()
buffer := make([]uint16, 256)
for {
n, err := windows.GetFinalPathNameByHandle(windows.Handle(dir.Fd()), &buffer[0], uint32(len(buffer)), 0)
if err != nil {
return "", err
}
if n < uint32(len(buffer)) {
return strings.ToLower(windows.UTF16ToString(buffer[:n])), nil
}
buffer = make([]uint16, n+1)
}
}
// fontNameLanguage 获取系统显示语言对应的OpenType名称表标识
// 返回: uint16 名称表平台标识, uint16 系统显示语言标识
func fontNameLanguage() (uint16, uint16) {