From f0c0b9db94e9e342c41b41cf77e95fd11d7dc881 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 25 Oct 2026 15:48:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=96=87=E4=BB=B6=E5=85=B3=E8=81=94):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9EWindows=E6=96=87=E4=BB=B6=E5=85=B3=E8=81=94?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 6 +++ assets/webui/ofdgo.css | 48 +++++++++++++++++++++++ assets/webui/ofdgo.js | 56 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- internal/webview/webview.go | 77 ++++++++++++++++++++++++++++++++++++- 6 files changed, 189 insertions(+), 4 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index c85f105..12f94e4 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -202,6 +202,12 @@
桌面软件https://ofdgo.aite.me/releases/
项目仓库https://github.com/xiaoqidun/ofdgo
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index e44dc02..ac330da 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -964,6 +964,54 @@ button:not(:disabled):hover { overflow-wrap: anywhere; } +.about-associations { + padding: 12px; + border-top: 1px solid var(--line); +} + +.association-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + font-weight: 600; + white-space: nowrap; +} + +#associationPermission { + color: var(--text-muted); + font-size: 12px; + font-weight: normal; +} + +.association-row { + display: grid; + grid-template-columns: 4em minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + white-space: nowrap; +} + +.association-row + .association-row { + margin-top: 12px; +} + +.association-state { + color: var(--text-muted); +} + +.association-actions { + display: flex; + gap: 8px; +} + +#associationMessage { + margin-top: 12px; + white-space: nowrap; + overflow-x: auto; +} + .form-title { margin: -12px -12px 12px; padding: 10px 12px; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 01cec7e..040bc9c 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -196,6 +196,9 @@ const el = { aboutButton: document.querySelector("#aboutButton"), aboutPanel: document.querySelector("#aboutPanel"), aboutVersion: document.querySelector("#aboutVersion"), + associationSection: document.querySelector("#associationSection"), + associationPermission: document.querySelector("#associationPermission"), + associationMessage: document.querySelector("#associationMessage"), signButton: document.querySelector("#signButton"), signPanel: document.querySelector("#signPanel"), signForm: document.querySelector("#signForm"), @@ -1499,6 +1502,7 @@ el.pageForm.addEventListener("submit", async (event) => { } }); el.aboutButton.addEventListener("click", openAboutPanel); +el.associationSection.addEventListener("click", changeFileAssociation); let aboutBackdrop = false; el.aboutPanel.addEventListener("pointerdown", event => { aboutBackdrop = event.target === el.aboutPanel; }); el.aboutPanel.addEventListener("click", event => { @@ -2017,7 +2021,9 @@ function formDialogOpen() { async function openAboutPanel() { el.aboutVersion.textContent = "读取中"; + el.associationMessage.hidden = true; documentDialogs.showApplication(el.aboutPanel); + if (nativeHost) void refreshFileAssociations(); let raw = ""; try { const response = await fetch("./", { cache: "no-store" }); @@ -2041,6 +2047,56 @@ async function openAboutPanel() { el.aboutVersion.textContent = `${date.toISOString().slice(0, 10).replaceAll("-", "")}-${match[2].slice(0, 7)}`; } +async function refreshFileAssociations() { + if (!nativeHost || el.associationSection.dataset.busy) return; + el.associationSection.dataset.busy = "true"; + for (const control of el.associationSection.querySelectorAll("button")) control.disabled = true; + for (const label of el.associationSection.querySelectorAll(".association-state")) { + label.textContent = "查询中"; + label.title = ""; + } + try { + const states = await nativeHost.call("association.query", {}); + el.associationSection.hidden = !states.user.supported; + el.associationPermission.hidden = !states.machine.supported || states.machine.writable; + for (const row of el.associationSection.querySelectorAll(".association-row")) { + const state = states[row.dataset.scope]; + const label = row.querySelector(".association-state"); + label.textContent = state.error ? "不可用" : state.complete ? "已注册" : state.registered ? "待更新" : "未注册"; + label.title = state.error || ""; + row.querySelector('[data-action="register"]').disabled = !state.writable || !!state.error || state.complete; + row.querySelector('[data-action="remove"]').disabled = !state.writable || !!state.error || !state.registered; + } + } catch (error) { + el.associationSection.hidden = false; + el.associationPermission.hidden = true; + for (const label of el.associationSection.querySelectorAll(".association-state")) label.textContent = "不可用"; + el.associationMessage.textContent = `查询失败:${error.message}`; + el.associationMessage.hidden = false; + } finally { + delete el.associationSection.dataset.busy; + } +} + +async function changeFileAssociation(event) { + const button = event.target.closest("button[data-action]"); + if (!nativeHost || !button || button.disabled || el.associationSection.dataset.busy) return; + const row = button.closest(".association-row"); + el.associationSection.dataset.busy = "true"; + for (const control of el.associationSection.querySelectorAll("button")) control.disabled = true; + el.associationMessage.hidden = true; + row.querySelector(".association-state").textContent = button.dataset.action === "register" ? "注册中" : "撤销中"; + try { + await nativeHost.call(`association.${button.dataset.action}`, {scope:row.dataset.scope}); + } catch (error) { + el.associationMessage.textContent = `${row.firstElementChild.textContent}:${error.message}`; + el.associationMessage.hidden = false; + } finally { + delete el.associationSection.dataset.busy; + await refreshFileAssociations(); + } +} + function setObjectDimension(input, value) { input.value = String(Number(value.toFixed(2))); input.defaultValue = input.value; diff --git a/go.mod b/go.mod index d48baca..30c8469 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-20261025062141-103903c8c889 + github.com/xiaoqidun/guigo v0.0.0-20261025072734-26f8f43c3631 github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5 diff --git a/go.sum b/go.sum index 61678f1..60ff82f 100644 --- a/go.sum +++ b/go.sum @@ -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-20261025062141-103903c8c889 h1:0R5ejN3yEatqBUSgavo5V+XBXfaqTNlhuyYBVCyM6iI= -github.com/xiaoqidun/guigo v0.0.0-20261025062141-103903c8c889/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls= +github.com/xiaoqidun/guigo v0.0.0-20261025072734-26f8f43c3631 h1:iAIPGYhNvmA/eodRZqChfK4jqXKbABQfDtO3mwCrSWc= +github.com/xiaoqidun/guigo v0.0.0-20261025072734-26f8f43c3631/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= diff --git a/internal/webview/webview.go b/internal/webview/webview.go index a767427..1cde24c 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -67,6 +67,12 @@ type Host struct { closeAllowed bool } +// associationState 保存单个范围的登记状态和查询错误 +type associationState struct { + guigo.AssociationStatus + Error string `json:"error,omitempty"` +} + // engineClose 保存并发关闭共享的完成通知及结果 type engineClose struct { done chan struct{} @@ -104,7 +110,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", "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", "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) @@ -370,6 +376,10 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { return applicationUpdate(ctx, build.Version, applicationVersionURL) case "update.open": return nil, guigo.OpenURL(applicationDownloadURL) + case "association.query": + return queryAssociations() + case "association.register", "association.remove": + return nil, changeAssociations(text("scope"), name == "association.remove") case "engine.close": return nil, h.closeEngine(text("engine")) case "engine.finish": @@ -632,6 +642,71 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { return nil, fmt.Errorf("未知宿主操作") } +// associationOptions 提供Windows文件关联的固定应用配置 +// 返回: guigo.AssociationOptions 应用配置, error 路径错误 +func associationOptions() (guigo.AssociationOptions, error) { + executable, err := os.Executable() + if err != nil { + return guigo.AssociationOptions{}, err + } + return guigo.AssociationOptions{ + AppID: "OFDGo", Name: "OFDGo", Description: "OFD、PDF阅读与编辑工具", + Executable: executable, Arguments: []string{"--"}, + Types: []guigo.FileAssociation{ + {ID: "OFD", Name: "OFD文档", Extensions: []string{".ofd"}}, + {ID: "PDF", Name: "PDF文档", Extensions: []string{".pdf"}}, + }, + }, nil +} + +// queryAssociations 分别查询两个范围,不因其中一个失败而隐藏另一个 +// 返回: any 两个范围的状态, error 配置错误 +func queryAssociations() (any, error) { + options, err := associationOptions() + if err != nil { + return nil, err + } + result := make(map[string]associationState) + for _, scope := range []guigo.AssociationScope{guigo.AssociationUser, guigo.AssociationMachine} { + state, err := guigo.QueryFileAssociations(options, scope) + row := associationState{AssociationStatus: state} + if err != nil { + row.Error = associationError(err).Error() + } + result[string(scope)] = row + } + return result, nil +} + +// changeAssociations 执行固定应用配置的单范围注册或撤销 +// 入参: scope 作用范围, remove 是否撤销 +// 返回: error 操作错误 +func changeAssociations(scope string, remove bool) error { + options, err := associationOptions() + if err != nil { + return err + } + if remove { + err = guigo.UnregisterFileAssociations(options, guigo.AssociationScope(scope)) + } else { + err = guigo.RegisterFileAssociations(options, guigo.AssociationScope(scope)) + } + return associationError(err) +} + +// associationError 区分未提升权限和实际访问失败,提供简短提示 +// 入参: err 操作错误 +// 返回: error 用户提示或原始错误 +func associationError(err error) error { + if errors.Is(err, guigo.ErrAssociationElevationRequired) { + return errors.New("需管理员权限") + } + if errors.Is(err, os.ErrPermission) { + return errors.New("无权访问") + } + return err +} + // applicationUpdate 比较官网和当前程序的完整构建版本 // 入参: ctx 请求上下文, version 当前版本, endpoint 官网地址 // 返回: bool 是否有更新, error 检查错误