From 8158824604a43d449633dbeadb44d76c13497291 Mon Sep 17 00:00:00 2001 From: Larry Zhu Date: Wed, 28 Jan 2026 15:34:25 +0800 Subject: [PATCH 01/51] =?UTF-8?q?feat:=20ir=20=E6=A8=A1=E5=BC=8F=E5=9F=BA?= =?UTF-8?q?=E6=9C=AC=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lang/index.json | 48 ++ src/plugins/customPastePlugin.ts | 86 ++-- src/plugins/laxImagePlugin.ts | 8 +- .../components/editor/MilkdownEditor.vue | 8 +- src/renderer/enhance/crepe/config/index.ts | 176 ++++---- .../enhance/crepe/plugins/sourceOnFocus.ts | 425 ++++++++++++++++++ src/renderer/styles/milkdown.less | 74 +++ 7 files changed, 696 insertions(+), 129 deletions(-) create mode 100644 src/renderer/enhance/crepe/plugins/sourceOnFocus.ts diff --git a/lang/index.json b/lang/index.json index 5621a11..ebf1d9b 100644 --- a/lang/index.json +++ b/lang/index.json @@ -2494,5 +2494,53 @@ "ru": "", "en": "", "fr": "" + }, + "wzudk98": { + "zh-cn": "双击修改 URL", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "v66gef9": { + "zh-cn": "修改链接 URL:", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "a6fdb94": { + "zh-cn": "上传文件", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "l9122": { + "zh-cn": "确认", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ieee177": { + "zh-cn": "粘贴链接...", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ikzkgf4": { + "zh-cn": "选择文件", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" } } diff --git a/src/plugins/customPastePlugin.ts b/src/plugins/customPastePlugin.ts index 4ec1cef..98ba4b5 100644 --- a/src/plugins/customPastePlugin.ts +++ b/src/plugins/customPastePlugin.ts @@ -1,70 +1,80 @@ -import type { Uploader } from '@milkdown/kit/plugin/upload' -import type { Node, Schema } from '@milkdown/kit/prose/model' -import { uploadImage } from '@/renderer/services/api' +import type { Uploader } from "@milkdown/kit/plugin/upload"; +import type { Node, Schema } from "@milkdown/kit/prose/model"; +import { uploadImage } from "@/renderer/services/api"; export const uploader: Uploader = async (files, schema) => { - const images: File[] = [] - const pasteMethod = localStorage.getItem('pasteMethod') as 'local' | 'base64' | 'remote' + const images: File[] = []; + const pasteMethod = localStorage.getItem("pasteMethod") as "local" | "base64" | "remote"; for (let i = 0; i < files.length; i++) { - const file = files.item(i) + const file = files.item(i); if (!file) { - continue + continue; } // You can handle whatever the file type you want, we handle image here. - if (!file.type.includes('image')) { - continue + if (!file.type.includes("image")) { + continue; } - images.push(file) + images.push(file); } - const nodes: Node[] = [] + const nodes: Node[] = []; for (const image of images) { - if (pasteMethod === 'base64') { - const base64 = await turnToBase64(image) - nodes.push(schema.nodes.image.createAndFill({ src: base64, alt: image.name }) as Node) - continue + if (pasteMethod === "base64") { + const base64 = await turnToBase64(image); + nodes.push(schema.nodes.image.createAndFill({ src: base64, alt: image.name }) as Node); + continue; } - if (pasteMethod === 'remote') { + if (pasteMethod === "remote") { try { - await upload(image, nodes, schema) + await upload(image, nodes, schema); } catch (error) { - console.error('Image upload failed:', error) - continue + console.error("Image upload failed:", error); + continue; } } - if (pasteMethod === 'local') { + if (pasteMethod === "local") { try { - await local(image, nodes, schema) + await local(image, nodes, schema); } catch (error) { - console.error('Local image handling failed:', error) - continue + console.error("Local image handling failed:", error); + continue; } } } - return nodes -} + return nodes; +}; function turnToBase64(file: File): Promise { return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = error => reject(error) - reader.readAsDataURL(file) - }) + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + reader.readAsDataURL(file); + }); } async function upload(image: File, nodes: Node[], schema: Schema) { - const src = await uploadImage(image) - nodes.push(schema.nodes.image.createAndFill({ src, alt: image.name }) as Node) + const src = await uploadImage(image); + nodes.push(schema.nodes.image.createAndFill({ src, alt: image.name }) as Node); } async function local(image: File, nodes: Node[], schema: Schema) { - const filePath = await window.electronAPI.getFilePathInClipboard() + // 在 Electron 环境下,File 对象可能包含 path 属性(代表绝对路径) + const absolutePath = (image as any).path; + if (absolutePath) { + nodes.push(schema.nodes.image.createAndFill({ src: absolutePath, alt: image.name }) as Node); + return; + } + + const filePath = await window.electronAPI.getFilePathInClipboard(); if (filePath) { - nodes.push(schema.nodes.image.createAndFill({ src: filePath, alt: image.name }) as Node) + nodes.push(schema.nodes.image.createAndFill({ src: filePath, alt: image.name }) as Node); } else { - const arrayBuffer = await image.arrayBuffer() - const buffer = new Uint8Array(arrayBuffer) + const arrayBuffer = await image.arrayBuffer(); + const buffer = new Uint8Array(arrayBuffer); // Convert Uint8Array to ArrayBuffer to satisfy the ArrayBufferLike parameter - const tempPath = await window.electronAPI.writeTempImage(buffer, localStorage.getItem('localImagePath') || '/temp') - nodes.push(schema.nodes.image.createAndFill({ src: tempPath, alt: image.name }) as Node) + const tempPath = await window.electronAPI.writeTempImage( + buffer, + localStorage.getItem("localImagePath") || "/temp" + ); + nodes.push(schema.nodes.image.createAndFill({ src: tempPath, alt: image.name }) as Node); } } diff --git a/src/plugins/laxImagePlugin.ts b/src/plugins/laxImagePlugin.ts index af4f5dd..bad60e5 100644 --- a/src/plugins/laxImagePlugin.ts +++ b/src/plugins/laxImagePlugin.ts @@ -4,7 +4,7 @@ import { $prose } from "@milkdown/utils"; // 匹配Markdown图片语法的正则:![alt](src) // 允许 src 中包含空格 -const wrappingImageRegex = /!\[([^\]]*)\]\(([^)]+)\)$/; +const wrappingImageRegex = /!\[([^\]]*)\]\(([^)]*)\)$/; export const laxImageInputRule = $prose((_ctx) => { return inputRules({ @@ -14,7 +14,7 @@ export const laxImageInputRule = $prose((_ctx) => { const [_, alt, src] = match; const { tr } = state; - if (!src) return null; + // if (!src) return null; // 将 src 中的空格替换为 %20,确保 Milkdown 能正确解析 const encodedSrc = src.replace(/ /g, "%20"); @@ -47,7 +47,7 @@ export const laxImagePastePlugin = $prose((_ctx) => { console.log("[Debug] handlePaste text:", text); // 放宽正则:使用 g 标志匹配所有 - const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + const imageRegex = /!\[([^\]]*)\]\(([^)]*)\)/g; // 检查是否有匹配项,并且这些匹配项中是否存在带空格的路径 let match; @@ -66,7 +66,7 @@ export const laxImagePastePlugin = $prose((_ctx) => { // 但如果用户粘贴的是纯图片语法,我们可以直接构造节点。 // 简单策略:如果整个粘贴内容就是一张图片,且带空格,我们接管。 - const singleImageRegex = /^!\[([^\]]*)\]\(([^)]+)\)$/; + const singleImageRegex = /^!\[([^\]]*)\]\(([^)]*)\)$/; const singleMatch = text.trim().match(singleImageRegex); if (singleMatch) { diff --git a/src/renderer/components/editor/MilkdownEditor.vue b/src/renderer/components/editor/MilkdownEditor.vue index 0b30808..4cfcd62 100644 --- a/src/renderer/components/editor/MilkdownEditor.vue +++ b/src/renderer/components/editor/MilkdownEditor.vue @@ -14,6 +14,7 @@ import { uploader } from "@/plugins/customPastePlugin"; import { htmlPlugin } from "@/plugins/hybridHtmlPlugin/rawHtmlPlugin"; import { processImagePaths, reverseProcessImagePaths } from "@/plugins/imagePathPlugin"; import { laxImageInputRule, laxImagePastePlugin } from "@/plugins/laxImagePlugin"; +import { sourceOnFocusPlugin } from "@renderer/enhance/crepe/plugins/sourceOnFocus"; import { diagram } from "@/plugins/mermaidPlugin"; import emitter from "@/renderer/events"; import useTab from "@/renderer/hooks/useTab"; @@ -72,7 +73,7 @@ onMounted(async () => { // 预处理:将图片路径中的空格转换为 %20,确保 crepe 能正确渲染 // 匹配 ![alt](path) 格式 contentForRendering = contentForRendering.replace( - /!\[([^\]]*)\]\(([^)]+)\)/g, + /!\[([^\]]*)\]\(([^)]*)\)/g, (match, alt, src) => { if (src.includes(" ")) { console.log("[Debug] Found image with space during load:", src); @@ -107,7 +108,7 @@ onMounted(async () => { // 后处理:将图片路径中的 %20 还原为空格(如果需要) // 匹配 ![alt](path) 格式 restoredMarkdown = restoredMarkdown.replace( - /!\[([^\]]*)\]\(([^)]+)\)/g, + /!\[([^\]]*)\]\(([^)]*)\)/g, (match, alt, src) => { if (src.includes("%20")) { console.log("[Debug] decoding image path for save:", src); @@ -165,6 +166,7 @@ onMounted(async () => { .use(upload) .use(htmlPlugin) .use(diagram) + .use(sourceOnFocusPlugin) .use(commonmark); props.readOnly && crepe.setReadonly(true); @@ -189,7 +191,7 @@ onMounted(async () => { // 预处理:将图片路径中的空格转换为 %20 contentForRendering = contentForRendering.replace( - /!\[([^\]]*)\]\(([^)]+)\)/g, + /!\[([^\]]*)\]\(([^)]*)\)/g, (match, alt, src) => { if (src.includes(" ")) { console.log("[Debug] Found image with space during update:", src); diff --git a/src/renderer/enhance/crepe/config/index.ts b/src/renderer/enhance/crepe/config/index.ts index 4edc1c8..4c78b20 100644 --- a/src/renderer/enhance/crepe/config/index.ts +++ b/src/renderer/enhance/crepe/config/index.ts @@ -1,145 +1,153 @@ -import type { EnhanceBlockEditFeatureConfig, EnhanceCrepeFeatureConfig } from '@renderer/enhance/crepe/types' -import { autocompletion } from '@codemirror/autocomplete' -import { EditorState } from '@codemirror/state' -import { CrepeFeature } from '@milkdown/crepe' -import autotoast from 'autotoast.js' +import type { + EnhanceBlockEditFeatureConfig, + EnhanceCrepeFeatureConfig, +} from "@renderer/enhance/crepe/types"; +import { autocompletion } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { CrepeFeature } from "@milkdown/crepe"; +import autotoast from "autotoast.js"; export const enhanceBlockEditConfig: EnhanceBlockEditFeatureConfig = { textGroup: { - label: '文本', + label: "文本", text: { - label: '文本', - abbr: ['text', 'wb', 'wenben'], + label: "文本", + abbr: ["text", "wb", "wenben"], }, h1: { - label: '一级标题', - abbr: ['h1', 'yjbt', 'bt1', 'yijibiaoti'], + label: "一级标题", + abbr: ["h1", "yjbt", "bt1", "yijibiaoti"], }, h2: { - label: '二级标题', - abbr: ['h2', 'ejbt', 'bt2', 'erjibiaoti'], + label: "二级标题", + abbr: ["h2", "ejbt", "bt2", "erjibiaoti"], }, h3: { - label: '三级标题', - abbr: ['h3', 'sjbt', 'bt3', 'sanjibiaoti'], + label: "三级标题", + abbr: ["h3", "sjbt", "bt3", "sanjibiaoti"], }, h4: { - label: '四级标题', - abbr: ['h4', 'sjbt', 'bt4', 'sijibiaoti'], + label: "四级标题", + abbr: ["h4", "sjbt", "bt4", "sijibiaoti"], }, h5: { - label: '五级标题', - abbr: ['h5', 'wjbt', 'bt5', 'wujibiaoti'], + label: "五级标题", + abbr: ["h5", "wjbt", "bt5", "wujibiaoti"], }, h6: { - label: '六级标题', - abbr: ['h6', 'ljbt', 'bt6', 'liujibiaoti'], + label: "六级标题", + abbr: ["h6", "ljbt", "bt6", "liujibiaoti"], }, quote: { - label: '引用', - abbr: ['quote', 'yy', 'yinyong'], + label: "引用", + abbr: ["quote", "yy", "yinyong"], }, divider: { - label: '分割线', - abbr: ['divider', 'fgx', 'dd', 'fengexian'], + label: "分割线", + abbr: ["divider", "fgx", "dd", "fengexian"], }, }, listGroup: { - label: '列表', + label: "列表", bulletList: { - label: '无序列表', - abbr: ['bullet-list', 'lb', 'wxlb', 'wuxuliebiao'], + label: "无序列表", + abbr: ["bullet-list", "lb", "wxlb", "wuxuliebiao"], }, orderedList: { - label: '有序列表', - abbr: ['ordered-list', 'lb', 'yxlb', 'youxuliebiao'], + label: "有序列表", + abbr: ["ordered-list", "lb", "yxlb", "youxuliebiao"], }, taskList: { - label: '任务列表', - abbr: ['task-list', 'lb', 'rwlb', 'renwuliebiao'], + label: "任务列表", + abbr: ["task-list", "lb", "rwlb", "renwuliebiao"], }, }, advancedGroup: { - label: '高级', + label: "高级", image: { - label: '图片', - abbr: ['image', 'tp', 'photo', 'tupian'], + label: "图片", + abbr: ["image", "tp", "photo", "tupian"], }, codeBlock: { - label: '代码块', - abbr: ['code-block', 'dmk', 'code', 'daimakuai'], + label: "代码块", + abbr: ["code-block", "dmk", "code", "daimakuai"], }, table: { - label: '表格', - abbr: ['table', 'tb', 'bg', 'biaoge'], + label: "表格", + abbr: ["table", "tb", "bg", "biaoge"], }, math: { - label: '公式', - abbr: ['math', 'gs', 'gongshi'], + label: "公式", + abbr: ["math", "gs", "gongshi"], }, }, -} +}; export const chinesePhrases = { // @codemirror/view - 'Control character': '控制字符', + "Control character": "控制字符", // @codemirror/commands - 'Selection deleted': '选择已删除', + "Selection deleted": "选择已删除", // @codemirror/language - 'Folded lines': '已折叠行', - 'Unfolded lines': '已展开行', - 'to': '到', - 'folded code': '已折叠代码', - 'unfold': '展开', - 'Fold line': '折叠行', - 'Unfold line': '展开行', + "Folded lines": "已折叠行", + "Unfolded lines": "已展开行", + to: "到", + "folded code": "已折叠代码", + unfold: "展开", + "Fold line": "折叠行", + "Unfold line": "展开行", // @codemirror/search - 'Go to line': '跳转到行', - 'go': '确定', - 'Find': '查找', - 'Replace': '替换为', - 'next': '下一个', - 'previous': '上一个', - 'all': '全部', - 'match case': '区分大小写', - 'by word': '全字匹配', - 'replace': '替换', - 'replace all': '替换全部', - 'close': '关闭', - 'current match': '当前匹配', - 'replaced $ matches': '已替换 $ 个匹配项', - 'replaced match on line $': '在第 $ 行替换匹配项', - 'on line': '在行', + "Go to line": "跳转到行", + go: "确定", + Find: "查找", + Replace: "替换为", + next: "下一个", + previous: "上一个", + all: "全部", + "match case": "区分大小写", + "by word": "全字匹配", + replace: "替换", + "replace all": "替换全部", + close: "关闭", + "current match": "当前匹配", + "replaced $ matches": "已替换 $ 个匹配项", + "replaced match on line $": "在第 $ 行替换匹配项", + "on line": "在行", // @codemirror/autocomplete - 'Completions': '自动补全', + Completions: "自动补全", // @codemirror/lint - 'Diagnostics': '诊断信息', - 'No diagnostics': '无诊断信息', - 'Regexp': '正则', - 'regexp': '正则', -} + Diagnostics: "诊断信息", + "No diagnostics": "无诊断信息", + Regexp: "正则", + regexp: "正则", +}; export const enhanceConfig: EnhanceCrepeFeatureConfig = { [CrepeFeature.CodeMirror]: { - extensions: [ - EditorState.phrases.of(chinesePhrases), - autocompletion(), - ], - searchPlaceholder: '搜索语言', - noResultText: '暂无匹配', - copyText: '复制', + extensions: [EditorState.phrases.of(chinesePhrases), autocompletion()], + searchPlaceholder: "搜索语言", + noResultText: "暂无匹配", + copyText: "复制", onCopy: (_) => { - autotoast.show('复制成功', 'success') + autotoast.show("复制成功", "success"); }, }, [CrepeFeature.LinkTooltip]: { onCopyLink: (_) => { - autotoast.show('复制成功', 'success') + autotoast.show("复制成功", "success"); }, }, [CrepeFeature.Placeholder]: { - text: '开始写点什么吧...', - mode: 'doc', + text: "开始写点什么吧...", + mode: "doc", + }, + [CrepeFeature.ImageBlock]: { + blockUploadButton: "选择文件", + blockConfirmButton: "确认", + blockUploadPlaceholderText: "粘贴链接...", + inlineUploadButton: "选择文件", + inlineConfirmButton: "确认", + inlineUploadPlaceholderText: "粘贴链接...", }, [CrepeFeature.BlockEdit]: enhanceBlockEditConfig, -} +}; diff --git a/src/renderer/enhance/crepe/plugins/sourceOnFocus.ts b/src/renderer/enhance/crepe/plugins/sourceOnFocus.ts new file mode 100644 index 0000000..d921e6d --- /dev/null +++ b/src/renderer/enhance/crepe/plugins/sourceOnFocus.ts @@ -0,0 +1,425 @@ +import { Plugin, PluginKey, TextSelection } from "@milkdown/prose/state"; +import { Decoration, DecorationSet } from "@milkdown/prose/view"; +import { $prose } from "@milkdown/utils"; +import { editorViewCtx } from "@milkdown/kit/core"; + +export const sourceOnFocusPluginKey = new PluginKey("MILKUP_SOURCE_ON_FOCUS"); + +export const sourceOnFocusPlugin = $prose((ctx) => { + return new Plugin({ + key: sourceOnFocusPluginKey, + state: { + init: () => DecorationSet.empty, + apply(tr) { + const { selection } = tr; + const decorations: Decoration[] = []; + const view = ctx.get(editorViewCtx); + + const handleImage = (img: any, pos: number, side: number = -1) => { + const { src, alt } = img.attrs; + decorations.push( + Decoration.widget( + pos, + () => { + const wrapper = document.createElement("span"); + wrapper.className = "md-source-image-wrapper"; + wrapper.textContent = "!["; + const altSpan = document.createElement("span"); + altSpan.className = "md-source-alt-editable"; + altSpan.contentEditable = "true"; + altSpan.textContent = alt || ""; + altSpan.style.caretColor = "var(--text-color-3)"; + + const moveFocus = (isRight: boolean) => { + view.focus(); + const targetPos = isRight ? pos + 1 : pos; + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, targetPos)) + ); + }; + + const commitAlt = () => { + const newAlt = altSpan.textContent || ""; + if (newAlt === alt) return; + view.dispatch( + view.state.tr.setNodeMarkup(pos, null, { ...img.attrs, alt: newAlt }) + ); + }; + + altSpan.addEventListener("keydown", (e) => { + const sel = window.getSelection(); + if (e.key === "ArrowLeft" && sel?.anchorOffset === 0) { + e.preventDefault(); + commitAlt(); + moveFocus(false); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commitAlt(); + view.focus(); + return; + } + e.stopPropagation(); + }); + + altSpan.addEventListener("blur", () => { + commitAlt(); + }); + wrapper.appendChild(altSpan); + wrapper.appendChild(document.createTextNode("](")); + const srcSpan = document.createElement("span"); + srcSpan.className = "md-source-src-editable"; + srcSpan.contentEditable = "true"; + srcSpan.textContent = src; + srcSpan.style.caretColor = "var(--text-color-3)"; + + const commitSrc = () => { + const newSrc = srcSpan.textContent || ""; + if (newSrc === src) return; + view.dispatch( + view.state.tr.setNodeMarkup(pos, null, { ...img.attrs, src: newSrc }) + ); + }; + + srcSpan.addEventListener("keydown", (e) => { + const sel = window.getSelection(); + if (e.key === "ArrowRight" && sel?.anchorOffset === srcSpan.textContent?.length) { + e.preventDefault(); + commitSrc(); + moveFocus(true); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commitSrc(); + view.focus(); + return; + } + e.stopPropagation(); + }); + + srcSpan.addEventListener("blur", () => { + commitSrc(); + }); + wrapper.appendChild(srcSpan); + wrapper.appendChild(document.createTextNode(")")); + return wrapper; + }, + { side } + ) + ); + }; + + // 0. 处理 NodeSelection (块级图片点击时) + if (!selection.empty) { + const selectedNode = (selection as any).node; + if ( + selectedNode && + (selectedNode.type.name === "image" || selectedNode.type.name === "image-block") + ) { + handleImage(selectedNode, selection.from, -1); + } + // 对于其他非空选区(如划词),不显示源码预览 + if (decorations.length > 0) return DecorationSet.create(tr.doc, decorations); + return DecorationSet.empty; + } + + const { $from } = selection; + + // 1. 处理 Marks + const marks = $from.marks(); + marks.forEach((mark) => { + const type = mark.type.name; + if ( + ["strong", "emphasis", "code_inline", "strike_through"].includes(type) || + type === "link" + ) { + let prefix = "", + suffix = ""; + if (type === "strong") prefix = suffix = "**"; + else if (type === "emphasis") prefix = suffix = "*"; + else if (type === "code_inline") prefix = suffix = "`"; + else if (type === "strike_through") prefix = suffix = "~~"; + else if (type === "link") { + prefix = "["; + suffix = "]"; + } + + let start = $from.pos, + end = $from.pos; + $from.doc.nodesBetween( + $from.start($from.depth), + $from.end($from.depth), + (node, pos) => { + if (node.isText && mark.isInSet(node.marks)) { + if (pos < start) start = pos; + if (pos + node.nodeSize > end) end = pos + node.nodeSize; + } + } + ); + + if (prefix) { + decorations.push( + Decoration.widget( + start, + () => { + const el = document.createElement("span"); + el.className = "md-source md-source-prefix"; + el.textContent = prefix; + return el; + }, + { side: -1 } + ) + ); + } + if (suffix) { + decorations.push( + Decoration.widget( + end, + () => { + const el = document.createElement("span"); + el.className = "md-source md-source-suffix"; + el.textContent = suffix; + return el; + }, + { side: 1 } + ) + ); + } + + if (type === "link") { + const { href } = mark.attrs; + decorations.push( + Decoration.widget( + end, + () => { + const wrapper = document.createElement("span"); + wrapper.className = "md-source-url-wrapper"; + wrapper.textContent = "("; + const span = document.createElement("span"); + span.className = "md-source-url-editable"; + span.contentEditable = "true"; + span.textContent = href; + span.style.caretColor = "var(--text-color-3)"; + span.setAttribute("data-pos", end.toString()); + + const commitChange = () => { + const newHref = span.textContent || ""; + if (newHref === href) return; + view.dispatch( + view.state.tr + .removeMark(start, end, mark.type) + .addMark(start, end, mark.type.create({ ...mark.attrs, href: newHref })) + ); + }; + + span.addEventListener("keydown", (e) => { + const sel = window.getSelection(); + + // 向左跳出逻辑 + if (e.key === "ArrowLeft" && sel?.anchorOffset === 0) { + e.preventDefault(); + commitChange(); + view.focus(); + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, end)) + ); + return; + } + // 向右跳出逻辑 + if ( + e.key === "ArrowRight" && + sel?.anchorOffset === span.textContent?.length + ) { + e.preventDefault(); + commitChange(); + view.focus(); + // 移动到 end + 1 处,确保彻底跳出链接的 Mark 范围并关闭源码预览 + const nextPos = Math.min(view.state.doc.content.size, end + 1); + view.dispatch( + view.state.tr + .setSelection(TextSelection.create(view.state.doc, nextPos)) + .setStoredMarks([]) + ); + return; + } + // 提交逻辑 + if (e.key === "Enter") { + e.preventDefault(); + commitChange(); + view.focus(); + return; + } + + // 阻止其他键冒泡给 ProseMirror(如字母、数字、空格、删除键等) + // 这样可以确保 these 按键在 contentEditable 元素内正常工作 + e.stopPropagation(); + }); + + span.addEventListener("blur", () => { + commitChange(); + }); + wrapper.appendChild(span); + wrapper.appendChild(document.createTextNode(")")); + return wrapper; + }, + { side: 2 } + ) + ); + } + } + }); + + // 2. 处理 Nodes + const node = $from.parent; + if (node.type.name === "heading") { + const prefix = "#".repeat(node.attrs.level) + " "; + decorations.push( + Decoration.widget( + $from.start(), + () => { + const el = document.createElement("span"); + el.className = "md-source md-source-heading"; + el.textContent = prefix; + return el; + }, + { side: -1 } + ) + ); + } + + const nodeAfter = $from.nodeAfter; + const nodeBefore = $from.nodeBefore; + + if ( + nodeAfter && + (nodeAfter.type.name === "image" || nodeAfter.type.name === "image-block") + ) { + handleImage(nodeAfter, $from.pos, -1); + } else if ( + nodeBefore && + (nodeBefore.type.name === "image" || nodeBefore.type.name === "image-block") + ) { + handleImage(nodeBefore, $from.pos - 1, 1); + } else if (node.type.name === "image-block") { + // 场景:光标就在 image-block 内部(节点选区或类似的) + handleImage(node, $from.before(), -1); + } + + return DecorationSet.create(tr.doc, decorations); + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + handleKeyDown(view, event) { + const { selection } = view.state; + const { $from } = selection; + + if (event.key === "ArrowRight" && selection.empty) { + // 处理链接:在链接末尾按右键进入 URL 编辑 + const marks = $from.marks(); + const linkMark = marks.find((m) => m.type.name === "link"); + if (linkMark) { + // 计算当前链接的精确范围 + let markStart = $from.pos, + markEnd = $from.pos; + $from.doc.nodesBetween( + $from.start($from.depth), + $from.end($from.depth), + (node, pos) => { + if (node.isText && linkMark.isInSet(node.marks)) { + if (pos < markStart) markStart = pos; + if (pos + node.nodeSize > markEnd) markEnd = pos + node.nodeSize; + } + } + ); + + if ($from.pos === markEnd) { + const el = view.dom.querySelector( + `.md-source-url-editable[data-pos="${markEnd}"]` + ) as HTMLElement; + if (el) { + el.focus(); + // 确保光标在最前面 + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(true); + sel?.removeAllRanges(); + sel?.addRange(range); + return true; + } + } + } + + // 处理图片 + if ( + $from.nodeAfter?.type.name === "image" || + $from.nodeAfter?.type.name === "image-block" + ) { + const el = view.dom.querySelector(".md-source-alt-editable") as HTMLElement; + if (el) { + el.focus(); + return true; + } + } + + // 处理加粗/斜体/内联代码等 Mark 的跳出 + const specialMarks = ["strong", "emphasis", "code_inline", "strike_through", "link"]; + const activeSpecialMark = marks.find((m) => specialMarks.includes(m.type.name)); + + if (activeSpecialMark) { + // 获取当前所有 special mark 的最大结束位置 + let maxEnd = $from.pos; + marks.forEach((mark) => { + if (specialMarks.includes(mark.type.name)) { + $from.doc.nodesBetween( + $from.start($from.depth), + $from.end($from.depth), + (node, pos) => { + if (node.isText && mark.isInSet(node.marks)) { + maxEnd = Math.max(maxEnd, pos + node.nodeSize); + } + } + ); + } + }); + + if ($from.pos === maxEnd) { + const nextPos = Math.min(view.state.doc.content.size, maxEnd + 1); + view.dispatch( + view.state.tr + .setSelection(TextSelection.create(view.state.doc, nextPos)) + .setStoredMarks([]) + ); + return true; // 拦截默认行为,防止跳到下一行 + } + } + } + if (event.key === "ArrowLeft") { + if ( + $from.nodeBefore?.type.name === "image" || + $from.nodeBefore?.type.name === "image-block" + ) { + const el = view.dom.querySelector(".md-source-src-editable") as HTMLElement; + if (el) { + // 将光标设置到末尾 + el.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(el); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + return true; + } + } + } + return false; + }, + }, + }); +}); diff --git a/src/renderer/styles/milkdown.less b/src/renderer/styles/milkdown.less index 3b9dee2..159406a 100644 --- a/src/renderer/styles/milkdown.less +++ b/src/renderer/styles/milkdown.less @@ -200,4 +200,78 @@ .milkdown .milkdown-image-block>.image-wrapper img { height: auto !important; +} + +// Markdown source decorations +.md-source { + color: var(--text-color-4); + font-family: var(--milkup-font-code); + font-size: 0.9em; + font-weight: normal; + font-style: normal; + opacity: 0.6; + user-select: none; + pointer-events: none; + background: var(--background-color-2); + padding: 0 2px; + border-radius: 2px; + margin: 0 1px; + vertical-align: baseline; +} + +.md-source-prefix { + margin-right: 2px; +} + +.md-source-suffix { + margin-left: 2px; +} + +.md-source-heading { + display: inline; + margin-right: 8px; + opacity: 0.5; + font-size: 0.9em; + vertical-align: baseline; + color: var(--text-color-4); +} + +.md-source-url-wrapper, .md-source-image-wrapper { + display: inline; + color: var(--text-color-4); + opacity: 0.7; + font-family: var(--milkup-font-code); + font-size: 0.9em; + background: var(--background-color-2); + padding: 0 4px; + border-radius: 4px; +} + +.md-source-url-editable, .md-source-alt-editable, .md-source-src-editable { + display: inline-block; + min-width: 10px; + outline: none; + cursor: text; + user-select: text; + pointer-events: auto; + color: var(--text-color-3); + border-bottom: 1px dashed var(--border-color); + padding: 0 2px; + caret-color: var(--text-color-1); +} + +.md-source-image { + display: inline-block; + margin: 0 4px; + font-size: 12px; + padding: 2px 4px; + opacity: 0.8; +} + +.md-source-url { + opacity: 0.7; + margin-left: 4px; + pointer-events: auto; // 允许点击或选择 + user-select: text; + cursor: pointer; } \ No newline at end of file From 339e1c73bc8ea4604ccc8cce0b58218b38578519 Mon Sep 17 00:00:00 2001 From: Larry Zhu Date: Fri, 30 Jan 2026 11:59:50 +0800 Subject: [PATCH 02/51] chore: skills --- .agent/skills/README.md | 123 + .agent/skills/milkup-dev/SKILL.md | 600 +++++ .../skills/moai-framework-electron/SKILL.md | 283 +++ .../moai-framework-electron/examples.md | 2082 +++++++++++++++++ .../moai-framework-electron/reference.md | 1649 +++++++++++++ .claude/settings.local.json | 5 + 6 files changed, 4742 insertions(+) create mode 100644 .agent/skills/README.md create mode 100644 .agent/skills/milkup-dev/SKILL.md create mode 100644 .agent/skills/moai-framework-electron/SKILL.md create mode 100644 .agent/skills/moai-framework-electron/examples.md create mode 100644 .agent/skills/moai-framework-electron/reference.md create mode 100644 .claude/settings.local.json diff --git a/.agent/skills/README.md b/.agent/skills/README.md new file mode 100644 index 0000000..d710ec5 --- /dev/null +++ b/.agent/skills/README.md @@ -0,0 +1,123 @@ +# milkup 项目专属 Skills + +这个目录包含了为 milkup 项目定制的 Claude Code skills,用于简化常见的开发任务。 + +## 可用的 Skills + +### 1. milkup-build +**用途**:构建和打包应用 + +用于构建 milkup 应用的不同平台版本(Windows、macOS、Linux),支持多种架构(x64、arm64)。 + +**使用方式**: +```bash +/milkup-build +``` + +**主要功能**: +- 开发构建 +- 生产构建 +- 平台特定构建(Windows/macOS/Linux) +- 多架构支持(x64/arm64) + +--- + +### 2. milkup-dev +**用途**:开发环境管理 + +用于启动开发服务器、运行 Electron 应用、进行热重载调试等开发任务。 + +**使用方式**: +```bash +/milkup-dev +``` + +**主要功能**: +- 启动 Vite 开发服务器 +- 启动 Electron 应用 +- 热重载支持 +- 开发调试工具 + +--- + +### 3. milkup-lint +**用途**:代码质量检查 + +使用 oxlint 和 oxfmt 进行代码检查和格式化,确保代码符合项目规范。 + +**使用方式**: +```bash +/milkup-lint +``` + +**主要功能**: +- 代码语法检查 +- 代码格式化 +- 自动修复问题 +- Git 钩子集成 + +--- + +### 4. milkup-release +**用途**:版本发布管理 + +管理版本号、生成更新日志、创建发布标签等版本发布相关任务。 + +**使用方式**: +```bash +/milkup-release +``` + +**主要功能**: +- 版本号管理(遵循 Semver) +- 自动生成更新日志 +- 创建 Git 标签 +- 发布流程指导 + +--- + +## 如何使用 + +在 Claude Code 中,你可以通过以下方式使用这些 skills: + +1. **直接调用**:在对话中输入 `/skill-name`,例如 `/milkup-build` +2. **自然语言**:描述你想做的事情,Claude 会自动选择合适的 skill + - "帮我构建 Windows 版本" → 自动使用 milkup-build + - "启动开发环境" → 自动使用 milkup-dev + - "检查代码质量" → 自动使用 milkup-lint + - "发布新版本" → 自动使用 milkup-release + +## Skills 之间的关系 + +``` +milkup-dev (开发) + ↓ +milkup-lint (检查) + ↓ +milkup-build (构建) + ↓ +milkup-release (发布) +``` + +典型的工作流程: +1. 使用 `milkup-dev` 启动开发环境进行开发 +2. 使用 `milkup-lint` 检查和格式化代码 +3. 使用 `milkup-build` 构建应用 +4. 使用 `milkup-release` 发布新版本 + +## 自定义和扩展 + +这些 skills 都是开源的,你可以根据项目需要进行修改和扩展: + +1. 编辑 `.agent/skills//SKILL.md` 文件 +2. 添加新的命令或工作流程 +3. 调整配置以适应项目变化 + +## 贡献 + +如果你创建了新的有用的 skill 或改进了现有的 skill,欢迎提交 PR 分享给社区! + +--- + +**注意**:这些 skills 是专门为 milkup 项目设计的,使用了项目特定的配置和工具。 +如果你想在其他项目中使用类似的 skills,需要根据具体项目进行调整。 diff --git a/.agent/skills/milkup-dev/SKILL.md b/.agent/skills/milkup-dev/SKILL.md new file mode 100644 index 0000000..cae59b4 --- /dev/null +++ b/.agent/skills/milkup-dev/SKILL.md @@ -0,0 +1,600 @@ +--- +name: milkup-dev +description: > + milkup 项目开发环境管理专家,深入了解项目的技术栈(Electron + Vue 3 + Milkdown + TypeScript)、 + 架构设计和开发工作流。负责启动开发服务器、运行 Electron 应用、热重载调试等开发任务。 +license: MIT +compatibility: Designed for Claude Code +allowed-tools: Bash Read Grep Glob +metadata: + version: "2.0.0" + category: "development" + status: "active" + updated: "2026-01-30" + user-invocable: "true" + tags: "milkup, electron, vue3, milkdown, typescript, vite, development" + related-skills: "milkup-build, milkup-lint, moai-framework-electron" +--- + +# milkup 开发环境工具 + +## 快速参考 + +milkup-dev 是专门为 milkup 项目设计的开发环境管理工具,深入了解项目的技术栈和架构。 + +milkup 是一个现代化的桌面端 Markdown 编辑器,基于 Electron 37+ 构建,使用 Vue 3 作为前端框架, +Milkdown 作为编辑器核心,支持所见即所得(WYSIWYG)和源码编辑双模式。 + +自动触发:当用户请求启动开发环境、运行应用、进行开发调试或询问技术栈相关问题时。 + +### 技术栈概览 + +核心框架: + +- Electron 37+ - 桌面应用框架(Chromium 130 + Node.js 20.18) +- Vue 3.5+ - 渐进式前端框架,使用 Composition API +- TypeScript 5.9+ - 类型安全的 JavaScript 超集,启用 strict 模式 +- Vite 7+ - 下一代前端构建工具,提供极速的 HMR + +编辑器核心: + +- Milkdown 7.17+ - 基于 ProseMirror 的插件化 Markdown 编辑器 +- @milkdown/crepe - Milkdown 的所见即所得(WYSIWYG)模式 +- @milkdown/kit - Milkdown 完整插件套件 +- CodeMirror 6 - 源码编辑模式的代码编辑器 +- Vditor 3.11+ - 额外的 Markdown 编辑功能支持 + +构建工具链: + +- Vite - 渲染进程构建和开发服务器 +- esbuild - 主进程和预加载脚本的快速构建 +- vite-plugin-electron - Electron 与 Vite 的集成插件 +- electron-builder - 多平台应用打包工具 + +国际化与工具: + +- vite-auto-i18n-plugin - 自动国际化翻译插件 +- oxlint - 快速的 JavaScript/TypeScript 代码检查工具 +- oxfmt - 快速的代码格式化工具 +- simple-git-hooks - 轻量级 Git 钩子管理 + +UI 与增强: + +- @vueuse/core - Vue 组合式 API 工具集 +- vue-draggable-plus - 拖拽功能支持 +- autodialog.js - 对话框管理 +- autotoast.js - 通知提示管理 +- mermaid - 图表渲染支持 + +### 项目架构 + +进程架构: + +milkup 遵循 Electron 的多进程架构。主进程(Main Process)运行在 Node.js 环境中, +负责窗口管理、文件系统访问、IPC 通信处理和系统集成。渲染进程(Renderer Process) +运行在 Chromium 中,负责 UI 渲染和用户交互,通过预加载脚本(Preload Script) +与主进程进行安全的 IPC 通信。 + +目录结构: + +``` +src/ +├── main/ # 主进程代码 +│ ├── index.ts # 主进程入口,窗口创建和生命周期管理 +│ ├── ipcBridge/ # IPC 通信处理器 +│ ├── menu/ # 应用菜单定义 +│ └── update/ # 自动更新逻辑 +├── renderer/ # 渲染进程代码(Vue 应用) +│ ├── App.vue # 根组件 +│ ├── main.ts # 渲染进程入口 +│ ├── components/ # Vue 组件 +│ │ ├── editor/ # 编辑器组件(Milkdown、CodeMirror) +│ │ ├── workspace/ # 工作区和标签页管理 +│ │ ├── outline/ # 文档大纲导航 +│ │ ├── menu/ # 菜单栏和状态栏 +│ │ ├── settings/ # 设置页面 +│ │ ├── dialogs/ # 对话框组件 +│ │ └── ui/ # 通用 UI 组件 +│ ├── hooks/ # Vue 组合式函数 +│ ├── services/ # 业务逻辑服务 +│ ├── plugins/ # Milkdown 插件 +│ ├── styles/ # 样式文件 +│ └── utils/ # 工具函数 +├── preload.ts # 预加载脚本,暴露安全的 API 给渲染进程 +├── shared/ # 主进程和渲染进程共享的类型和常量 +├── types/ # TypeScript 类型定义 +├── themes/ # 主题系统 +├── plugins/ # 应用级插件 +└── config/ # 配置文件 +``` + +双窗口系统: + +milkup 实现了双窗口架构。主窗口(Main Window)是主要的编辑器界面, +包含编辑器、工作区、大纲和菜单栏。主题编辑器窗口(Theme Editor Window) +是一个独立的模态窗口,用于自定义和编辑主题样式。两个窗口通过 IPC 通信 +共享状态和数据。 + +路径别名配置: + +项目配置了三个路径别名以简化导入: +- `@/` - 指向 `src/` 目录,用于访问所有源代码 +- `@renderer/` - 指向 `src/renderer/` 目录,用于渲染进程代码 +- `@ui/` - 指向 `src/renderer/components/ui/` 目录,用于 UI 组件 + +### 核心功能特性 + +编辑器模式: + +milkup 支持两种编辑模式的无缝切换。所见即所得模式(WYSIWYG)使用 Milkdown +的 Crepe 主题,提供类似 Typora 的即时渲染体验。源码模式使用 CodeMirror 6, +提供语法高亮、代码折叠和 Markdown 语法支持。两种模式共享同一份文档数据, +切换时保持光标位置和滚动状态。 + +文件管理: + +应用支持多文件标签页管理,可以同时打开多个 Markdown 文件。工作区面板 +显示文件树,支持文件夹展开/折叠和文件拖拽。文件关联功能允许双击 .md 文件 +直接在 milkup 中打开。支持通过命令行参数启动时打开指定文件。 + +自定义协议: + +实现了 `milkup://` 自定义协议用于加载本地图片。协议格式为 +`milkup:////`, +解决了 Electron 中加载相对路径图片的安全限制问题。主进程注册协议处理器, +将相对路径解析为绝对路径并返回文件内容。 + +主题系统: + +支持多主题切换和自定义主题编辑。主题文件使用 Less 编写,支持变量和嵌套。 +主题编辑器提供可视化界面,可以实时预览主题效果。支持主题导入导出, +方便分享和备份。 + +国际化支持: + +使用 vite-auto-i18n-plugin 实现自动国际化。支持中文、英文、日文、韩文、 +俄文和法文。翻译文件自动生成,开发时只需编写中文文本,构建时自动翻译 +为其他语言。 + +### 常用命令 + +启动开发服务器: +```bash +pnpm run dev +``` +此命令会依次执行: +1. 构建预加载脚本(esbuild) +2. 构建主进程代码(esbuild) +3. 启动 Vite 开发服务器 +4. 自动启动 Electron 应用(通过 vite-plugin-electron) + +启动 Electron 应用: +```bash +pnpm run start:electron +``` +此命令用于单独启动 Electron 应用,不启动 Vite 开发服务器。 + +构建主进程: +```bash +pnpm run build:main +``` + +构建预加载脚本: +```bash +pnpm run build:preload +``` + +--- + +## 实现指南 + +### 开发环境设置 + +环境要求: + +开发 milkup 需要以下环境: +- Node.js >= 20.17.0(项目使用 Node.js 20.18 的特性) +- pnpm >= 10.0.0(项目使用 pnpm 10.12.1) +- Git(用于版本控制) +- 支持的操作系统:Windows、macOS 或 Linux + +依赖安装: + +首次克隆项目后,执行 `pnpm install` 安装所有依赖。项目配置了 preinstall +钩子,会检查是否使用 pnpm,如果使用其他包管理器会报错。安装过程中会自动 +执行 simple-git-hooks 的 prepare 脚本,安装 Git 钩子。 + +开发服务器启动流程: + +执行 `pnpm run dev` 后的完整流程: + +1. esbuild 构建预加载脚本(src/preload.ts → dist-electron/preload.js) +2. esbuild 构建主进程代码(src/main/index.ts → dist-electron/main/index.js) +3. Vite 启动开发服务器,监听 src/renderer 目录 +4. vite-plugin-electron 自动启动 Electron 主进程 +5. 主进程创建 BrowserWindow,加载 Vite 开发服务器的 URL +6. 渲染进程加载 Vue 应用,初始化 Milkdown 编辑器 +7. 开发模式下自动打开 DevTools + +环境变量: + +开发模式下,vite-plugin-electron 会设置 `VITE_DEV_SERVER_URL` 环境变量, +主进程通过此变量判断是否为开发模式。开发模式下加载 Vite 开发服务器的 URL, +生产模式下加载本地 HTML 文件。 + +### 编辑器集成详解 + +Milkdown 集成: + +Milkdown 是基于 ProseMirror 的插件化 Markdown 编辑器。项目使用 @milkdown/vue +提供的 Vue 组件集成。编辑器配置包括: +- @milkdown/preset-commonmark - CommonMark 规范支持 +- @milkdown/preset-gfm - GitHub Flavored Markdown 支持 +- @milkdown/plugin-prism - 代码块语法高亮 +- @milkdown/plugin-diagram - Mermaid 图表支持 +- @milkdown/plugin-listener - 编辑器事件监听 +- @milkdown/plugin-automd - 自动 Markdown 格式化 + +Milkdown 编辑器组件位于 src/renderer/components/editor/MilkdownEditor.vue。 +编辑器实例通过 Vue 的 provide/inject 机制在组件树中共享,允许其他组件 +访问编辑器 API 进行内容操作。 + +CodeMirror 集成: + +CodeMirror 6 用于源码编辑模式。配置包括: +- @codemirror/lang-markdown - Markdown 语法支持 +- @codemirror/autocomplete - 自动补全 +- @codemirror/commands - 编辑器命令 +- codemirror-lang-mermaid - Mermaid 语法支持 + +CodeMirror 编辑器组件位于 src/renderer/components/editor/MarkdownSourceEditor.vue。 +编辑器状态通过 @codemirror/state 管理,视图通过 @codemirror/view 渲染。 + +编辑器模式切换: + +两种编辑器模式共享同一份 Markdown 文本数据。切换时,当前编辑器的内容 +会保存到共享状态中,然后新编辑器从共享状态加载内容。使用防抖机制避免 +频繁切换导致的性能问题。 + +### IPC 通信模式 + +安全通信架构: + +milkup 遵循 Electron 安全最佳实践。渲染进程启用 contextIsolation, +禁用 nodeIntegration。预加载脚本使用 contextBridge.exposeInMainWorld +暴露安全的 API 给渲染进程。渲染进程通过 window.electronAPI 访问主进程功能。 + +IPC 通信类型: + +项目使用三种 IPC 通信模式: + +1. invoke/handle 模式 - 用于请求-响应通信。渲染进程调用 ipcRenderer.invoke, + 主进程通过 ipcMain.handle 注册处理器并返回结果。适用于文件读写、 + 对话框显示等需要返回值的操作。 + +2. send/on 模式 - 用于单向通信。渲染进程调用 ipcRenderer.send 发送消息, + 主进程通过 ipcMain.on 监听。适用于通知类操作,不需要返回值。 + +3. webContents.send 模式 - 主进程主动向渲染进程发送消息。用于文件打开、 + 窗口关闭确认等主进程发起的操作。 + +IPC 处理器组织: + +主进程的 IPC 处理器分为三类,位于 src/main/ipcBridge/ 目录: +- 全局处理器 - 不依赖窗口实例的处理器 +- Handle 处理器 - 需要返回值的请求-响应处理器 +- On 处理器 - 单向消息处理器 + +### 热重载机制 + +渲染进程热重载: + +Vite 提供的 HMR(Hot Module Replacement)支持渲染进程的热重载。 +修改 Vue 组件、样式文件或 TypeScript 代码时,Vite 会通过 WebSocket +推送更新,浏览器端的 HMR 客户端接收更新并替换模块,无需刷新整个页面。 + +Vue 组件的热重载由 @vitejs/plugin-vue 处理,支持保持组件状态。 +修改组件模板或脚本时,只重新渲染受影响的组件,保持应用状态不变。 + +主进程热重载: + +vite-plugin-electron 监听主进程和预加载脚本的文件变化。检测到变化时, +自动重新构建并重启 Electron 进程。这个过程会关闭当前窗口并创建新窗口, +因此会丢失应用状态。 + +为了减少主进程重启的影响,建议将业务逻辑尽量放在渲染进程,主进程只 +处理必要的系统集成和 IPC 通信。 + +### 调试技巧 + +渲染进程调试: + +开发模式下,应用启动时会自动打开 Chrome DevTools。也可以通过快捷键 +Ctrl+Shift+I(macOS: Cmd+Option+I)或全局快捷键 Ctrl+Shift+I 打开。 + +DevTools 提供完整的调试功能: +- Console 面板 - 查看日志输出和执行 JavaScript 代码 +- Sources 面板 - 设置断点、单步调试、查看调用栈 +- Elements 面板 - 检查 DOM 结构和样式 +- Network 面板 - 监控网络请求(虽然桌面应用较少使用) +- Performance 面板 - 性能分析和火焰图 +- Memory 面板 - 内存分析和泄漏检测 + +Vue DevTools 集成: + +可以安装 Vue DevTools 浏览器扩展的独立版本用于调试 Vue 应用。 +在 DevTools 中可以查看组件树、组件状态、Vuex/Pinia 状态、事件追踪等。 + +主进程调试: + +主进程运行在 Node.js 环境中,可以使用 Node.js 调试器。有两种方式: + +1. VS Code 调试配置 - 在 .vscode/launch.json 中配置调试配置, + 设置 Electron 可执行文件路径和启动参数,可以在 VS Code 中 + 直接设置断点调试主进程代码。 + +2. 远程调试 - 使用 --inspect 或 --inspect-brk 标志启动 Electron, + 然后在 Chrome 中访问 chrome://inspect 连接调试器。 + +日志输出策略: + +主进程的 console.log 输出到终端,渲染进程的 console.log 输出到 DevTools。 +建议使用不同的日志前缀区分不同模块,例如 `[main]`、`[renderer]`、`[ipc]` 等。 + +对于复杂的调试场景,可以使用 electron-log 库统一管理日志,支持日志级别、 +文件输出和格式化。 + +Vue 组件调试: + +在 Vue 组件中可以使用以下技巧: +- 使用 `console.log` 在生命周期钩子中输出状态 +- 使用 Vue DevTools 查看组件的 props、data 和 computed +- 使用 `debugger` 语句设置断点 +- 使用 `watchEffect` 追踪响应式数据变化 + +Milkdown 调试: + +Milkdown 编辑器的调试较为复杂,因为涉及 ProseMirror 的内部状态。 +可以通过以下方式调试: +- 使用 @milkdown/plugin-listener 监听编辑器事件 +- 访问编辑器实例的 `ctx` 对象查看插件状态 +- 使用 ProseMirror DevTools 查看文档结构和事务 + +### 故障排查 + +常见开发问题: + +**端口占用**: +Vite 默认使用 5173 端口。如果端口被占用,Vite 会自动尝试下一个端口。 +检查终端输出确认实际使用的端口号。如果需要固定端口,在 vite.config.mts +中配置 `server.port`。 + +**白屏问题**: +如果应用启动后显示白屏,按以下步骤排查: +1. 打开 DevTools 查看 Console 是否有错误信息 +2. 检查 Network 面板确认资源是否正确加载 +3. 确认预加载脚本路径正确(dist-electron/preload.js) +4. 检查主进程终端输出是否有错误 +5. 确认 Vite 开发服务器正常运行 + +**热重载失效**: +如果修改代码后页面没有更新: +1. 确认 Vite 开发服务器正常运行,检查终端是否有错误 +2. 检查修改的文件是否在 Vite 监听的目录范围内(src/renderer) +3. 尝试手动刷新页面(Ctrl+R 或 Cmd+R) +4. 检查是否有语法错误导致 HMR 失败 +5. 重启开发服务器 + +**IPC 通信失败**: +如果渲染进程无法调用主进程功能: +1. 确认预加载脚本正确暴露了 API(使用 contextBridge) +2. 检查 IPC 通道名称是否匹配 +3. 确认主进程已注册对应的处理器 +4. 检查 contextIsolation 是否正确启用 +5. 在 DevTools Console 中检查 window.electronAPI 是否存在 + +**编辑器加载失败**: +如果 Milkdown 或 CodeMirror 编辑器无法正常显示: +1. 检查编辑器组件是否正确挂载 +2. 确认编辑器样式文件已正确导入 +3. 检查 Console 是否有插件加载错误 +4. 确认编辑器容器元素存在且有正确的尺寸 +5. 检查是否有 CSS 冲突影响编辑器样式 + +**构建失败**: +如果 esbuild 或 Vite 构建失败: +1. 检查 TypeScript 类型错误 +2. 确认所有导入路径正确 +3. 检查是否有循环依赖 +4. 确认 node_modules 完整安装 +5. 尝试删除 node_modules 和 pnpm-lock.yaml 重新安装 + +**主题不生效**: +如果自定义主题没有应用: +1. 检查主题文件路径是否正确 +2. 确认 Less 文件语法正确 +3. 检查主题变量是否正确覆盖 +4. 确认主题文件已在入口文件中导入 +5. 清除缓存并重新构建 + +### 开发工作流 + +典型开发流程: + +1. **启动开发环境** + - 执行 `pnpm run dev` 启动开发服务器 + - 等待 Electron 应用自动启动 + - 确认 DevTools 已打开 + +2. **功能开发** + - 在 src/renderer 中修改 Vue 组件或添加新组件 + - 保存文件后查看 HMR 自动更新效果 + - 使用 DevTools 调试和验证功能 + +3. **主进程开发** + - 修改 src/main 中的主进程代码 + - 保存后等待 Electron 自动重启 + - 在终端查看主进程日志输出 + +4. **IPC 通信开发** + - 在 src/main/ipcBridge 中添加 IPC 处理器 + - 在 src/preload.ts 中暴露 API + - 在渲染进程中通过 window.electronAPI 调用 + - 使用 TypeScript 类型确保类型安全 + +5. **样式调整** + - 修改 Less 文件或组件样式 + - 使用 DevTools Elements 面板实时调试样式 + - 保存后查看 HMR 更新效果 + +6. **代码检查** + - 定期执行 `pnpm run lint` 检查代码质量 + - 执行 `pnpm run format` 自动格式化代码 + - Git 提交前会自动运行 lint-staged + +7. **测试验证** + - 在开发环境中测试功能 + - 执行 `pnpm run build` 构建生产版本 + - 执行 `pnpm run start:electron` 测试构建产物 + +最佳实践: + +- 使用 TypeScript 类型系统避免类型错误 +- 遵循 Vue 3 Composition API 最佳实践 +- 保持组件单一职责,避免过大的组件 +- 使用 Vue 组合式函数(hooks)复用逻辑 +- IPC 通信使用类型安全的接口定义 +- 主进程代码保持简洁,业务逻辑放在渲染进程 +- 使用路径别名简化导入语句 +- 定期运行代码检查和格式化 +- 提交前确保代码通过 lint 检查 + +--- + +## 使用示例 + +### 启动开发环境 + +基本启动: +```bash +# 安装依赖(首次或依赖更新后) +pnpm install + +# 启动开发服务器和 Electron 应用 +pnpm run dev +``` + +应用会自动启动,DevTools 会自动打开。终端会显示 Vite 开发服务器的地址 +和构建信息。 + +单独构建主进程: +```bash +# 只构建主进程代码 +pnpm run build:main + +# 只构建预加载脚本 +pnpm run build:preload +``` + +### 开发新功能 + +添加新的 Vue 组件: +```bash +# 在 src/renderer/components 中创建新组件 +# 使用路径别名导入 +import MyComponent from '@renderer/components/MyComponent.vue' +``` + +添加新的 IPC 通信: +```typescript +// 1. 在 src/main/ipcBridge 中添加处理器 +ipcMain.handle('my-channel', async (event, arg) => { + // 处理逻辑 + return result +}) + +// 2. 在 src/preload.ts 中暴露 API +contextBridge.exposeInMainWorld('electronAPI', { + myFunction: (arg) => ipcRenderer.invoke('my-channel', arg) +}) + +// 3. 在渲染进程中调用 +const result = await window.electronAPI.myFunction(arg) +``` + +### 调试场景 + +调试渲染进程: +```bash +# 启动开发环境 +pnpm run dev + +# DevTools 会自动打开 +# 在 Sources 面板设置断点 +# 在 Console 面板执行代码 +``` + +调试主进程: +```bash +# 方式 1: 使用 VS Code 调试配置 +# 在 .vscode/launch.json 中配置后,按 F5 启动调试 + +# 方式 2: 使用 --inspect 标志 +# 修改 package.json 的 dev 脚本添加 --inspect +# 然后在 Chrome 中访问 chrome://inspect +``` + +### 测试构建 + +构建并测试: +```bash +# 完整构建 +pnpm run build + +# 启动构建后的应用 +pnpm run start:electron +``` + +这会加载 dist 目录中的构建产物,而不是 Vite 开发服务器。 +用于验证生产构建是否正常工作。 + +--- + +## 配合使用 + +- **milkup-build** - 构建生产版本和打包应用 +- **milkup-lint** - 代码质量检查和格式化 +- **milkup-release** - 版本发布和更新日志生成 +- **moai-framework-electron** - Electron 开发最佳实践和高级模式 + +--- + +## 技术资源 + +配置文件: +- vite.config.mts - Vite 和 Electron 插件配置 +- tsconfig.json - TypeScript 编译配置 +- package.json - 依赖和脚本配置 + +关键文件: +- src/main/index.ts - 主进程入口,窗口管理和生命周期 +- src/preload.ts - 预加载脚本,IPC API 暴露 +- src/renderer/main.ts - 渲染进程入口,Vue 应用初始化 +- src/renderer/App.vue - 根组件,应用布局 + +编辑器组件: +- src/renderer/components/editor/MilkdownEditor.vue - Milkdown 编辑器 +- src/renderer/components/editor/MarkdownSourceEditor.vue - CodeMirror 编辑器 + +官方文档: +- Electron 文档: https://www.electronjs.org/docs +- Vue 3 文档: https://vuejs.org/ +- Vite 文档: https://vitejs.dev/ +- Milkdown 文档: https://milkdown.dev/ +- CodeMirror 文档: https://codemirror.net/ + +--- + +Version: 2.0.0 +Last Updated: 2026-01-30 +Changes: 添加完整的技术栈说明、架构详解、开发工作流和调试指南 diff --git a/.agent/skills/moai-framework-electron/SKILL.md b/.agent/skills/moai-framework-electron/SKILL.md new file mode 100644 index 0000000..d3fbd45 --- /dev/null +++ b/.agent/skills/moai-framework-electron/SKILL.md @@ -0,0 +1,283 @@ +--- +name: moai-framework-electron +description: > + Electron 33+ desktop app development specialist covering Main/Renderer + process architecture, IPC communication, auto-update, packaging with + Electron Forge and electron-builder, and security best practices. Use when + building cross-platform desktop applications, implementing native OS + integrations, or packaging Electron apps for distribution. [KO: Electron + 데스크톱 앱, 크로스플랫폼 개발, IPC 통신] [JA: Electronデスクトップアプリ、 + クロスプラットフォーム開発] [ZH: Electron桌面应用、跨平台开发] +license: Apache-2.0 +compatibility: Designed for Claude Code +allowed-tools: Read Grep Glob mcp__context7__resolve-library-id mcp__context7__get-library-docs +metadata: + version: "2.0.0" + category: "framework" + status: "active" + updated: "2026-01-10" + modularized: "false" + user-invocable: "false" + tags: "electron, desktop, cross-platform, nodejs, chromium, ipc, auto-update, electron-builder, electron-forge" + context7-libraries: "/electron/electron, /electron/forge, /electron-userland/electron-builder" + related-skills: "moai-lang-typescript, moai-domain-frontend, moai-lang-javascript" +--- + +# Electron 33+ Desktop Development + +## Quick Reference + +Electron 33+ Desktop App Development Specialist enables building cross-platform desktop applications with web technologies. + +Auto-Triggers: Electron projects detected via electron.vite.config.ts or electron-builder.yml files, desktop app development requests, IPC communication pattern implementation + +### Core Capabilities + +Electron 33 Platform: + +- Chromium 130 rendering engine for modern web features +- Node.js 20.18 runtime for native system access +- Native ESM support in main process +- WebGPU API support for GPU-accelerated graphics + +Process Architecture: + +- Main process runs as single instance per application with full Node.js access +- Renderer processes display web content in sandboxed environments +- Preload scripts bridge main and renderer with controlled API exposure +- Utility processes handle background tasks without blocking UI + +IPC Communication: + +- Type-safe invoke/handle patterns for request-response communication +- contextBridge API for secure renderer access to main process functionality +- Event-based messaging for push notifications from main to renderer + +Auto-Update Support: + +- electron-updater integration with GitHub and S3 publishing +- Differential updates for smaller download sizes +- Update notification and installation management + +Packaging Options: + +- Electron Forge for integrated build tooling and plugin ecosystem +- electron-builder for flexible multi-platform distribution + +Security Features: + +- contextIsolation for preventing prototype pollution +- Sandbox enforcement for renderer process isolation +- Content Security Policy configuration +- Input validation patterns for IPC handlers + +### Project Initialization + +Creating a new Electron application requires running the create-electron-app command with the vite-typescript template. Install electron-builder as a development dependency for packaging. Add electron-updater as a runtime dependency for auto-update functionality. + +For detailed commands and configuration, see reference.md Quick Commands section. + +--- + +## Implementation Guide + +### Project Structure + +Recommended Directory Layout: + +The source directory should contain four main subdirectories: + +Main Directory: Contains the main process entry point, IPC handler definitions organized by domain, business logic services, and window management modules + +Preload Directory: Contains the preload script entry point and exposed API definitions that bridge main and renderer + +Renderer Directory: Contains the web application built with React, Vue, or Svelte, including the HTML entry point and Vite configuration + +Shared Directory: Contains TypeScript types and constants shared between main and renderer processes + +The project root should include the electron.vite.config.ts for build configuration, electron-builder.yml for packaging options, and resources directory for app icons and assets. + +### Main Process Setup + +Application Lifecycle Management: + +The main process initialization follows a specific sequence. First, enable sandbox globally using app.enableSandbox() to ensure all renderer processes run in isolated environments. Then request single instance lock to prevent multiple app instances from running simultaneously. + +Window creation should occur after the app ready event fires. Configure BrowserWindow with security-focused webPreferences including contextIsolation enabled, nodeIntegration disabled, sandbox enabled, and webSecurity enabled. Set the preload script path to expose safe APIs to the renderer. + +Handle platform-specific behaviors: on macOS, re-create windows when the dock icon is clicked if no windows exist. On other platforms, quit the application when all windows close. + +For implementation examples, see examples.md Main Process Entry Point section. + +### Type-Safe IPC Communication + +IPC Type Definition Pattern: + +Define an interface that maps channel names to their payload types. Group channels by domain such as file operations, window operations, and storage operations. This enables type checking for both main process handlers and renderer invocations. + +Main Process Handler Registration: + +Register IPC handlers in a dedicated module that imports from the shared types. Each handler should validate input using a schema validation library such as Zod before processing. Use ipcMain.handle for request-response patterns and return structured results. + +Preload Script Implementation: + +Create an API object that wraps ipcRenderer.invoke calls for each channel. Use contextBridge.exposeInMainWorld to make this API available in the renderer as window.electronAPI. Include cleanup functions for event listeners to prevent memory leaks. + +For complete IPC implementation patterns, see examples.md Type-Safe IPC Implementation section. + +### Security Best Practices + +Mandatory Security Settings: + +Every BrowserWindow must have webPreferences configured with four critical settings. contextIsolation must always be enabled to prevent renderer code from accessing Electron internals. nodeIntegration must always be disabled in renderer processes. sandbox must always be enabled for process-level isolation. webSecurity must never be disabled to maintain same-origin policy enforcement. + +Content Security Policy: + +Configure session-level CSP headers using webRequest.onHeadersReceived. Restrict default-src to self, script-src to self without unsafe-inline, and connect-src to allowed API domains. This prevents XSS attacks and unauthorized resource loading. + +Input Validation: + +Every IPC handler must validate inputs before processing. Prevent path traversal attacks by rejecting paths containing parent directory references. Validate file names against reserved characters. Use allowlists for permitted directories when implementing file access. + +For security implementation details, see reference.md Security Best Practices section. + +### Auto-Update Implementation + +Update Service Architecture: + +Create an UpdateService class that manages the electron-updater lifecycle. Initialize with the main window reference to enable UI notifications. Configure autoDownload as false to give users control over bandwidth usage. + +Event Handling: + +Handle update-available events by notifying the renderer and prompting the user for download confirmation. Track download-progress events to display progress indicators. Handle update-downloaded events by prompting for restart. + +User Notification Pattern: + +Use system dialogs to prompt users when updates are available and when downloads complete. Send events to the renderer for in-app notification display. Support both immediate and deferred installation. + +For complete update service implementation, see examples.md Auto-Update Integration section. + +### App Packaging + +Electron Builder Configuration: + +Configure the appId with reverse-domain notation for platform registration. Specify productName for display in system UI. Set up platform-specific targets for macOS, Windows, and Linux. + +macOS Configuration: + +Set category for App Store classification. Enable hardenedRuntime and configure entitlements for notarization. Configure universal builds targeting both x64 and arm64 architectures. + +Windows Configuration: + +Specify icon path for executable and installer. Configure NSIS installer options including installation directory selection. Set up code signing with appropriate hash algorithms. + +Linux Configuration: + +Configure category for desktop environment integration. Set up multiple targets including AppImage for universal distribution and deb/rpm for package manager installation. + +For complete configuration examples, see reference.md Configuration section. + +--- + +## Advanced Patterns + +For comprehensive documentation on advanced topics, see reference.md and examples.md: + +Window State Persistence: + +- Saving and restoring window position and size across sessions +- Handling multiple displays and display changes +- Managing maximized and fullscreen states + +Multi-Window Management: + +- Creating secondary windows with proper parent-child relationships +- Sharing state between multiple windows +- Coordinating window lifecycle events + +System Tray and Native Menus: + +- Creating and updating system tray icons with context menus +- Building application menus with keyboard shortcuts +- Platform-specific menu patterns for macOS and Windows + +Utility Processes: + +- Spawning utility processes for CPU-intensive background tasks +- Communicating with utility processes via MessageChannel +- Managing utility process lifecycle and error handling + +Native Module Integration: + +- Rebuilding native modules for Electron Node.js version +- Using better-sqlite3 for local database storage +- Integrating keytar for secure credential storage + +Protocol Handlers and Deep Linking: + +- Registering custom URL protocols for app launching +- Handling deep links on different platforms +- OAuth callback handling via custom protocols + +Performance Optimization: + +- Lazy loading windows and heavy modules +- Optimizing startup time with deferred initialization +- Memory management for long-running applications + +--- + +## Works Well With + +- moai-lang-typescript - TypeScript patterns for type-safe Electron development +- moai-domain-frontend - React, Vue, or Svelte renderer development +- moai-lang-javascript - Node.js patterns for main process +- moai-domain-backend - Backend API integration +- moai-workflow-testing - Testing strategies for desktop apps + +--- + +## Troubleshooting + +Common Issues and Solutions: + +White Screen on Launch: + +Verify the preload script path is correctly configured relative to the built output directory. Check that loadFile or loadURL paths point to existing files. Enable devTools to inspect console errors. Review CSP settings that may block script execution. + +IPC Not Working: + +Confirm channel names match exactly between main handlers and renderer invocations. Ensure handlers are registered before windows load content. Verify contextBridge usage follows the correct pattern with exposeInMainWorld. + +Native Modules Fail: + +Run electron-rebuild after npm install to recompile native modules. Match the Node.js version embedded in Electron. Add a postinstall script to automate rebuilding. + +Auto-Update Not Working: + +Verify the application is code-signed as updates require signing. Check publish configuration in electron-builder.yml. Enable electron-updater logging to diagnose connection issues. Review firewall settings that may block update checks. + +Debug Commands: + +Rebuild native modules with npx electron-rebuild. Check Electron version with npx electron --version. Enable verbose update logging with DEBUG=electron-updater environment variable. + +--- + +## Resources + +For complete code examples and configuration templates, see: + +- reference.md - Detailed API documentation, version matrix, Context7 library mappings +- examples.md - Production-ready code examples for all patterns + +For latest documentation, use Context7 to query: + +- /electron/electron for core Electron APIs +- /electron/forge for Electron Forge tooling +- /electron-userland/electron-builder for packaging configuration + +--- + +Version: 2.0.0 +Last Updated: 2026-01-10 +Changes: Restructured to comply with CLAUDE.md Documentation Standards - removed all code examples, converted to narrative text format diff --git a/.agent/skills/moai-framework-electron/examples.md b/.agent/skills/moai-framework-electron/examples.md new file mode 100644 index 0000000..7fc5328 --- /dev/null +++ b/.agent/skills/moai-framework-electron/examples.md @@ -0,0 +1,2082 @@ +# Electron Framework Examples + +Production-ready code examples for Electron 33+ desktop application development. + +--- + +## Complete Electron App Setup with Vite + +### Package Configuration + +```json +// package.json +{ + "name": "electron-app", + "version": "1.0.0", + "main": "dist/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "preview": "electron-vite preview", + "package": "electron-builder", + "package:mac": "electron-builder --mac", + "package:win": "electron-builder --win", + "package:linux": "electron-builder --linux", + "postinstall": "electron-builder install-app-deps" + }, + "dependencies": { + "electron-store": "^8.1.0", + "electron-updater": "^6.1.7" + }, + "devDependencies": { + "@electron-toolkit/preload": "^3.0.0", + "@electron-toolkit/utils": "^3.0.0", + "@types/node": "^20.10.0", + "@vitejs/plugin-react": "^4.2.0", + "electron": "^33.0.0", + "electron-builder": "^24.9.1", + "electron-vite": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} +``` + +### Electron Vite Configuration + +```typescript +// electron.vite.config.ts +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "src/main/index.ts"), + }, + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "src/preload/index.ts"), + }, + }, + }, + }, + renderer: { + root: resolve(__dirname, "src/renderer"), + plugins: [react()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "src/renderer/index.html"), + }, + }, + }, + }, +}); +``` + +### Main Process Entry Point + +```typescript +// src/main/index.ts +import { app, BrowserWindow, ipcMain, session, shell } from "electron"; +import { join } from "path"; +import { electronApp, optimizer, is } from "@electron-toolkit/utils"; +import { registerIpcHandlers } from "./ipc"; +import { UpdateService } from "./services/updater"; +import { WindowManager } from "./windows/window-manager"; + +const windowManager = new WindowManager(); +const updateService = new UpdateService(); + +async function createMainWindow(): Promise { + const mainWindow = windowManager.createWindow("main", { + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 15, y: 15 }, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + // Open external links in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); + + // Load app + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); + mainWindow.webContents.openDevTools({ mode: "detach" }); + } else { + mainWindow.loadFile(join(__dirname, "../renderer/index.html")); + } + + return mainWindow; +} + +app.whenReady().then(async () => { + // Set app user model ID for Windows + electronApp.setAppUserModelId("com.example.myapp"); + + // Watch for shortcuts to optimize new windows + app.on("browser-window-created", (_, window) => { + optimizer.watchWindowShortcuts(window); + }); + + // Configure session security + configureSession(); + + // Register IPC handlers + registerIpcHandlers(); + + // Create main window + const mainWindow = await createMainWindow(); + + // Initialize auto-updater + if (!is.dev) { + updateService.initialize(mainWindow); + updateService.checkForUpdates(); + } + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +function configureSession(): void { + // Content Security Policy + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "font-src 'self' data:; " + + "connect-src 'self' https://api.github.com", + ], + }, + }); + }); + + // Permission handler + session.defaultSession.setPermissionRequestHandler( + (webContents, permission, callback) => { + const allowedPermissions = ["notifications", "clipboard-read"]; + callback(allowedPermissions.includes(permission)); + }, + ); + + // Block navigation to external URLs + session.defaultSession.setPermissionCheckHandler(() => false); +} + +// Single instance lock +const gotSingleLock = app.requestSingleInstanceLock(); +if (!gotSingleLock) { + app.quit(); +} else { + app.on("second-instance", (_event, _commandLine, _workingDirectory) => { + const mainWindow = windowManager.getWindow("main"); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} +``` + +--- + +## Type-Safe IPC Implementation + +### Shared Type Definitions + +```typescript +// src/shared/types/ipc.ts +export interface FileInfo { + path: string; + content: string; + encoding?: BufferEncoding; +} + +export interface SaveResult { + success: boolean; + path: string; + error?: string; +} + +export interface DialogOptions { + title?: string; + filters?: { name: string; extensions: string[] }[]; + defaultPath?: string; +} + +export interface StorageItem { + key: string; + value: T; + timestamp?: number; +} + +// IPC Channel Definitions +export interface IpcMainToRenderer { + "app:update-available": { version: string; releaseNotes: string }; + "app:update-progress": { + percent: number; + transferred: number; + total: number; + }; + "app:update-downloaded": { version: string }; + "window:maximize-change": boolean; + "file:external-open": { path: string }; +} + +export interface IpcRendererToMain { + // File operations + "file:open-dialog": DialogOptions; + "file:save-dialog": DialogOptions; + "file:read": string; + "file:write": { path: string; content: string }; + "file:exists": string; + + // Window operations + "window:minimize": void; + "window:maximize": void; + "window:close": void; + "window:is-maximized": void; + + // Storage operations + "storage:get": string; + "storage:set": StorageItem; + "storage:delete": string; + "storage:clear": void; + + // App operations + "app:get-version": void; + "app:get-path": "home" | "appData" | "userData" | "temp" | "downloads"; + "app:open-external": string; + + // Update operations + "update:check": void; + "update:download": void; + "update:install": void; +} + +// Return types for IPC handlers +export interface IpcReturnTypes { + "file:open-dialog": FileInfo | null; + "file:save-dialog": string | null; + "file:read": string; + "file:write": SaveResult; + "file:exists": boolean; + "window:minimize": void; + "window:maximize": void; + "window:close": void; + "window:is-maximized": boolean; + "storage:get": unknown; + "storage:set": void; + "storage:delete": void; + "storage:clear": void; + "app:get-version": string; + "app:get-path": string; + "app:open-external": void; + "update:check": void; + "update:download": void; + "update:install": void; +} +``` + +### Main Process IPC Handlers + +```typescript +// src/main/ipc/index.ts +import { ipcMain, dialog, app, shell, BrowserWindow } from "electron"; +import { readFile, writeFile, access } from "fs/promises"; +import { constants } from "fs"; +import Store from "electron-store"; +import { z } from "zod"; +import type { + DialogOptions, + FileInfo, + SaveResult, + StorageItem, +} from "../../shared/types/ipc"; + +const store = new Store({ + encryptionKey: process.env.STORE_ENCRYPTION_KEY, +}); + +// Validation schemas +const FilePathSchema = z + .string() + .min(1) + .refine( + (path) => { + const normalized = path.replace(/\\/g, "/"); + return !normalized.includes("..") && !normalized.includes("\0"); + }, + { message: "Invalid file path" }, + ); + +const StorageKeySchema = z + .string() + .min(1) + .max(256) + .regex(/^[a-zA-Z0-9_.-]+$/); + +export function registerIpcHandlers(): void { + // File operations + ipcMain.handle( + "file:open-dialog", + async (_event, options: DialogOptions): Promise => { + const result = await dialog.showOpenDialog({ + title: options.title ?? "Open File", + properties: ["openFile"], + filters: options.filters ?? [{ name: "All Files", extensions: ["*"] }], + defaultPath: options.defaultPath, + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + const path = result.filePaths[0]; + const content = await readFile(path, "utf-8"); + return { path, content }; + }, + ); + + ipcMain.handle( + "file:save-dialog", + async (_event, options: DialogOptions): Promise => { + const result = await dialog.showSaveDialog({ + title: options.title ?? "Save File", + filters: options.filters ?? [{ name: "All Files", extensions: ["*"] }], + defaultPath: options.defaultPath, + }); + + return result.canceled ? null : (result.filePath ?? null); + }, + ); + + ipcMain.handle("file:read", async (_event, path: string): Promise => { + const validPath = FilePathSchema.parse(path); + return readFile(validPath, "utf-8"); + }); + + ipcMain.handle( + "file:write", + async ( + _event, + { path, content }: { path: string; content: string }, + ): Promise => { + try { + const validPath = FilePathSchema.parse(path); + await writeFile(validPath, content, "utf-8"); + return { success: true, path: validPath }; + } catch (error) { + return { + success: false, + path, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); + + ipcMain.handle( + "file:exists", + async (_event, path: string): Promise => { + try { + const validPath = FilePathSchema.parse(path); + await access(validPath, constants.F_OK); + return true; + } catch { + return false; + } + }, + ); + + // Window operations + ipcMain.handle("window:minimize", (event): void => { + BrowserWindow.fromWebContents(event.sender)?.minimize(); + }); + + ipcMain.handle("window:maximize", (event): void => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window?.isMaximized()) { + window.unmaximize(); + } else { + window?.maximize(); + } + }); + + ipcMain.handle("window:close", (event): void => { + BrowserWindow.fromWebContents(event.sender)?.close(); + }); + + ipcMain.handle("window:is-maximized", (event): boolean => { + return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false; + }); + + // Storage operations + ipcMain.handle("storage:get", (_event, key: string): unknown => { + const validKey = StorageKeySchema.parse(key); + return store.get(validKey); + }); + + ipcMain.handle("storage:set", (_event, { key, value }: StorageItem): void => { + const validKey = StorageKeySchema.parse(key); + store.set(validKey, value); + }); + + ipcMain.handle("storage:delete", (_event, key: string): void => { + const validKey = StorageKeySchema.parse(key); + store.delete(validKey); + }); + + ipcMain.handle("storage:clear", (): void => { + store.clear(); + }); + + // App operations + ipcMain.handle("app:get-version", (): string => { + return app.getVersion(); + }); + + ipcMain.handle( + "app:get-path", + ( + _event, + name: "home" | "appData" | "userData" | "temp" | "downloads", + ): string => { + return app.getPath(name); + }, + ); + + ipcMain.handle( + "app:open-external", + async (_event, url: string): Promise => { + // Validate URL before opening + const parsedUrl = new URL(url); + if (parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:") { + await shell.openExternal(url); + } + }, + ); +} +``` + +### Preload Script with Full API + +```typescript +// src/preload/index.ts +import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; +import type { + DialogOptions, + FileInfo, + SaveResult, + StorageItem, + IpcMainToRenderer, +} from "../shared/types/ipc"; + +// Type-safe event listener helper +type EventCallback = (data: T) => void; +type Unsubscribe = () => void; + +function createEventListener( + channel: K, + callback: EventCallback, +): Unsubscribe { + const handler = (_event: IpcRendererEvent, data: IpcMainToRenderer[K]) => { + callback(data); + }; + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); +} + +const electronAPI = { + // File operations + file: { + openDialog: (options: DialogOptions = {}): Promise => + ipcRenderer.invoke("file:open-dialog", options), + saveDialog: (options: DialogOptions = {}): Promise => + ipcRenderer.invoke("file:save-dialog", options), + read: (path: string): Promise => + ipcRenderer.invoke("file:read", path), + write: (path: string, content: string): Promise => + ipcRenderer.invoke("file:write", { path, content }), + exists: (path: string): Promise => + ipcRenderer.invoke("file:exists", path), + }, + + // Window operations + window: { + minimize: (): Promise => ipcRenderer.invoke("window:minimize"), + maximize: (): Promise => ipcRenderer.invoke("window:maximize"), + close: (): Promise => ipcRenderer.invoke("window:close"), + isMaximized: (): Promise => + ipcRenderer.invoke("window:is-maximized"), + onMaximizeChange: (callback: (isMaximized: boolean) => void): Unsubscribe => + createEventListener("window:maximize-change", callback), + }, + + // Storage operations + storage: { + get: (key: string): Promise => + ipcRenderer.invoke("storage:get", key) as Promise, + set: (key: string, value: T): Promise => + ipcRenderer.invoke("storage:set", { key, value } as StorageItem), + delete: (key: string): Promise => + ipcRenderer.invoke("storage:delete", key), + clear: (): Promise => ipcRenderer.invoke("storage:clear"), + }, + + // App operations + app: { + getVersion: (): Promise => ipcRenderer.invoke("app:get-version"), + getPath: ( + name: "home" | "appData" | "userData" | "temp" | "downloads", + ): Promise => ipcRenderer.invoke("app:get-path", name), + openExternal: (url: string): Promise => + ipcRenderer.invoke("app:open-external", url), + }, + + // Update operations + update: { + check: (): Promise => ipcRenderer.invoke("update:check"), + download: (): Promise => ipcRenderer.invoke("update:download"), + install: (): Promise => ipcRenderer.invoke("update:install"), + onAvailable: ( + callback: (info: { version: string; releaseNotes: string }) => void, + ): Unsubscribe => createEventListener("app:update-available", callback), + onProgress: ( + callback: (progress: { + percent: number; + transferred: number; + total: number; + }) => void, + ): Unsubscribe => createEventListener("app:update-progress", callback), + onDownloaded: ( + callback: (info: { version: string }) => void, + ): Unsubscribe => createEventListener("app:update-downloaded", callback), + }, + + // Platform info + platform: { + isMac: process.platform === "darwin", + isWindows: process.platform === "win32", + isLinux: process.platform === "linux", + }, +}; + +contextBridge.exposeInMainWorld("electronAPI", electronAPI); + +// Type declaration for renderer +export type ElectronAPI = typeof electronAPI; + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} +``` + +--- + +## Auto-Update Integration + +### Complete Update Service + +```typescript +// src/main/services/updater.ts +import { + autoUpdater, + UpdateInfo, + ProgressInfo, + UpdateDownloadedEvent, +} from "electron-updater"; +import { BrowserWindow, dialog, Notification } from "electron"; +import log from "electron-log"; + +export interface UpdateServiceOptions { + /** Check for updates on startup */ + checkOnStartup?: boolean; + /** Auto-download updates */ + autoDownload?: boolean; + /** Auto-install on quit */ + autoInstallOnAppQuit?: boolean; + /** Check interval in milliseconds (default: 1 hour) */ + checkInterval?: number; +} + +export class UpdateService { + private mainWindow: BrowserWindow | null = null; + private checkIntervalId: NodeJS.Timeout | null = null; + + constructor( + private options: UpdateServiceOptions = { + checkOnStartup: true, + autoDownload: false, + autoInstallOnAppQuit: true, + checkInterval: 3600000, + }, + ) { + // Configure logging + autoUpdater.logger = log; + log.transports.file.level = "info"; + } + + initialize(window: BrowserWindow): void { + this.mainWindow = window; + + // Configure auto-updater + autoUpdater.autoDownload = this.options.autoDownload ?? false; + autoUpdater.autoInstallOnAppQuit = + this.options.autoInstallOnAppQuit ?? true; + + // Set up event handlers + this.setupEventHandlers(); + + // Check on startup if enabled + if (this.options.checkOnStartup) { + // Delay initial check to let app settle + setTimeout(() => this.checkForUpdates(), 5000); + } + + // Set up periodic checking + if (this.options.checkInterval && this.options.checkInterval > 0) { + this.checkIntervalId = setInterval( + () => this.checkForUpdates(), + this.options.checkInterval, + ); + } + } + + private setupEventHandlers(): void { + autoUpdater.on("checking-for-update", () => { + log.info("Checking for updates..."); + }); + + autoUpdater.on("update-available", (info: UpdateInfo) => { + log.info("Update available:", info.version); + this.notifyUpdateAvailable(info); + }); + + autoUpdater.on("update-not-available", () => { + log.info("No updates available"); + }); + + autoUpdater.on("error", (error: Error) => { + log.error("Update error:", error.message); + this.notifyError(error); + }); + + autoUpdater.on("download-progress", (progress: ProgressInfo) => { + log.info(`Download progress: ${progress.percent.toFixed(1)}%`); + this.mainWindow?.webContents.send("app:update-progress", { + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + }); + }); + + autoUpdater.on("update-downloaded", (event: UpdateDownloadedEvent) => { + log.info("Update downloaded:", event.version); + this.notifyUpdateDownloaded(event); + }); + } + + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + log.error("Failed to check for updates:", error); + } + } + + async downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate(); + } catch (error) { + log.error("Failed to download update:", error); + } + } + + installUpdate(): void { + autoUpdater.quitAndInstall(false, true); + } + + private async notifyUpdateAvailable(info: UpdateInfo): Promise { + // Send to renderer + this.mainWindow?.webContents.send("app:update-available", { + version: info.version, + releaseNotes: + typeof info.releaseNotes === "string" + ? info.releaseNotes + : (info.releaseNotes + ?.map((n) => `${n.version}: ${n.note}`) + .join("\n") ?? ""), + }); + + // Show system notification if supported + if (Notification.isSupported()) { + new Notification({ + title: "Update Available", + body: `Version ${info.version} is available for download.`, + }).show(); + } + + // Show dialog + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Available", + message: `A new version (${info.version}) is available.`, + detail: "Would you like to download and install it?", + buttons: ["Download", "Later"], + defaultId: 0, + cancelId: 1, + }); + + if (result.response === 0) { + this.downloadUpdate(); + } + } + + private async notifyUpdateDownloaded( + event: UpdateDownloadedEvent, + ): Promise { + // Send to renderer + this.mainWindow?.webContents.send("app:update-downloaded", { + version: event.version, + }); + + // Show dialog + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Ready", + message: `Version ${event.version} has been downloaded.`, + detail: "Would you like to restart and install it now?", + buttons: ["Restart Now", "Later"], + defaultId: 0, + cancelId: 1, + }); + + if (result.response === 0) { + this.installUpdate(); + } + } + + private notifyError(error: Error): void { + dialog.showErrorBox( + "Update Error", + `An error occurred while updating: ${error.message}`, + ); + } + + dispose(): void { + if (this.checkIntervalId) { + clearInterval(this.checkIntervalId); + this.checkIntervalId = null; + } + } +} +``` + +--- + +## System Tray and Native Menu + +### System Tray Service + +```typescript +// src/main/services/tray.ts +import { + Tray, + Menu, + MenuItemConstructorOptions, + app, + nativeImage, + BrowserWindow, +} from "electron"; +import { join } from "path"; +import { is } from "@electron-toolkit/utils"; + +export class TrayService { + private tray: Tray | null = null; + private mainWindow: BrowserWindow | null = null; + + initialize(mainWindow: BrowserWindow): void { + this.mainWindow = mainWindow; + + // Create tray icon + const iconPath = is.dev + ? join(__dirname, "../../resources/icons/tray.png") + : join(process.resourcesPath, "icons/tray.png"); + + // Use template icon on macOS + const icon = nativeImage.createFromPath(iconPath); + if (process.platform === "darwin") { + icon.setTemplateImage(true); + } + + this.tray = new Tray(icon); + this.tray.setToolTip(app.getName()); + + // Set context menu + this.updateContextMenu(); + + // Click behavior + this.tray.on("click", () => { + this.toggleMainWindow(); + }); + + // Double-click to show (Windows) + this.tray.on("double-click", () => { + this.showMainWindow(); + }); + } + + updateContextMenu(additionalItems: MenuItemConstructorOptions[] = []): void { + if (!this.tray) return; + + const contextMenu = Menu.buildFromTemplate([ + { + label: "Show App", + click: () => this.showMainWindow(), + }, + { type: "separator" }, + ...additionalItems, + { type: "separator" }, + { + label: "Preferences", + accelerator: "CmdOrCtrl+,", + click: () => this.openPreferences(), + }, + { type: "separator" }, + { + label: "Check for Updates", + click: () => this.checkForUpdates(), + }, + { type: "separator" }, + { + label: "Quit", + accelerator: "CmdOrCtrl+Q", + click: () => app.quit(), + }, + ]); + + this.tray.setContextMenu(contextMenu); + } + + private toggleMainWindow(): void { + if (!this.mainWindow) return; + + if (this.mainWindow.isVisible()) { + if (this.mainWindow.isFocused()) { + this.mainWindow.hide(); + } else { + this.mainWindow.focus(); + } + } else { + this.showMainWindow(); + } + } + + private showMainWindow(): void { + if (!this.mainWindow) return; + + this.mainWindow.show(); + this.mainWindow.focus(); + + // Restore if minimized + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + } + + private openPreferences(): void { + // Emit event or open preferences window + this.mainWindow?.webContents.send("app:open-preferences"); + } + + private checkForUpdates(): void { + this.mainWindow?.webContents.send("app:check-updates"); + } + + setBadge(text: string): void { + if (process.platform === "darwin") { + app.dock.setBadge(text); + } else if (this.tray) { + // Update tray title/tooltip for other platforms + this.tray.setTitle(text); + } + } + + destroy(): void { + this.tray?.destroy(); + this.tray = null; + } +} +``` + +### Application Menu + +```typescript +// src/main/services/menu.ts +import { + Menu, + app, + shell, + BrowserWindow, + MenuItemConstructorOptions, +} from "electron"; + +export function createApplicationMenu(mainWindow: BrowserWindow): void { + const isMac = process.platform === "darwin"; + + const template: MenuItemConstructorOptions[] = [ + // App menu (macOS only) + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + { + label: "Preferences", + accelerator: "CmdOrCtrl+,", + click: () => + mainWindow.webContents.send("app:open-preferences"), + }, + { type: "separator" as const }, + { role: "services" as const }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + } as MenuItemConstructorOptions, + ] + : []), + + // File menu + { + label: "File", + submenu: [ + { + label: "New", + accelerator: "CmdOrCtrl+N", + click: () => mainWindow.webContents.send("file:new"), + }, + { + label: "Open...", + accelerator: "CmdOrCtrl+O", + click: () => mainWindow.webContents.send("file:open"), + }, + { type: "separator" }, + { + label: "Save", + accelerator: "CmdOrCtrl+S", + click: () => mainWindow.webContents.send("file:save"), + }, + { + label: "Save As...", + accelerator: "CmdOrCtrl+Shift+S", + click: () => mainWindow.webContents.send("file:save-as"), + }, + { type: "separator" }, + isMac ? { role: "close" } : { role: "quit" }, + ], + }, + + // Edit menu + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(isMac + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [ + { role: "startSpeaking" as const }, + { role: "stopSpeaking" as const }, + ], + }, + ] + : []), + ], + }, + + // View menu + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + + // Window menu + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(isMac + ? [ + { type: "separator" as const }, + { role: "front" as const }, + { type: "separator" as const }, + { role: "window" as const }, + ] + : [{ role: "close" as const }]), + ], + }, + + // Help menu + { + label: "Help", + submenu: [ + { + label: "Documentation", + click: () => shell.openExternal("https://docs.example.com"), + }, + { + label: "Release Notes", + click: () => + shell.openExternal("https://github.com/example/repo/releases"), + }, + { type: "separator" }, + { + label: "Report Issue", + click: () => + shell.openExternal("https://github.com/example/repo/issues"), + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} +``` + +--- + +## Window State Persistence + +### Window Manager with State + +```typescript +// src/main/windows/window-manager.ts +import { + BrowserWindow, + BrowserWindowConstructorOptions, + screen, + Rectangle, +} from "electron"; +import Store from "electron-store"; +import { join } from "path"; + +interface WindowState { + width: number; + height: number; + x: number | undefined; + y: number | undefined; + isMaximized: boolean; + isFullScreen: boolean; +} + +interface WindowConfig { + id: string; + defaultWidth: number; + defaultHeight: number; + minWidth?: number; + minHeight?: number; +} + +const windowStateStore = new Store>({ + name: "window-state", +}); + +export class WindowManager { + private windows = new Map(); + private stateUpdateDebounce = new Map(); + + createWindow( + id: string, + options: BrowserWindowConstructorOptions = {}, + ): BrowserWindow { + // Get saved state or calculate default + const savedState = this.getWindowState(id); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + + const defaultConfig: WindowConfig = { + id, + defaultWidth: Math.floor(width * 0.8), + defaultHeight: Math.floor(height * 0.8), + minWidth: options.minWidth ?? 400, + minHeight: options.minHeight ?? 300, + }; + + // Calculate initial bounds + const bounds = this.calculateBounds(savedState, defaultConfig); + + const window = new BrowserWindow({ + ...bounds, + show: false, + ...options, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + ...options.webPreferences, + }, + }); + + // Restore maximized/fullscreen state + if (savedState?.isMaximized) { + window.maximize(); + } + if (savedState?.isFullScreen) { + window.setFullScreen(true); + } + + // Show when ready + window.once("ready-to-show", () => { + window.show(); + }); + + // Track state changes + this.trackWindowState(id, window); + + // Handle close + window.on("closed", () => { + this.windows.delete(id); + const timeout = this.stateUpdateDebounce.get(id); + if (timeout) { + clearTimeout(timeout); + this.stateUpdateDebounce.delete(id); + } + }); + + this.windows.set(id, window); + return window; + } + + getWindow(id: string): BrowserWindow | undefined { + const window = this.windows.get(id); + if (window && !window.isDestroyed()) { + return window; + } + return undefined; + } + + getAllWindows(): BrowserWindow[] { + return Array.from(this.windows.values()).filter((w) => !w.isDestroyed()); + } + + closeWindow(id: string): void { + const window = this.getWindow(id); + if (window) { + window.close(); + } + } + + closeAll(): void { + for (const window of this.getAllWindows()) { + window.close(); + } + } + + private getWindowState(id: string): WindowState | undefined { + return windowStateStore.get(id); + } + + private saveWindowState(id: string, state: WindowState): void { + windowStateStore.set(id, state); + } + + private calculateBounds( + savedState: WindowState | undefined, + config: WindowConfig, + ): Rectangle { + const { width, height, x, y } = screen.getPrimaryDisplay().workAreaSize; + + // Use saved state if available and valid + if (savedState && this.isValidBounds(savedState)) { + return { + width: savedState.width, + height: savedState.height, + x: savedState.x ?? Math.floor((width - savedState.width) / 2), + y: savedState.y ?? Math.floor((height - savedState.height) / 2), + }; + } + + // Center window with default size + return { + width: config.defaultWidth, + height: config.defaultHeight, + x: Math.floor((width - config.defaultWidth) / 2), + y: Math.floor((height - config.defaultHeight) / 2), + }; + } + + private isValidBounds(state: WindowState): boolean { + const displays = screen.getAllDisplays(); + + // Check if window is visible on any display + return displays.some((display) => { + const { x, y, width, height } = display.bounds; + const windowX = state.x ?? 0; + const windowY = state.y ?? 0; + + return ( + windowX >= x - state.width && + windowX <= x + width && + windowY >= y - state.height && + windowY <= y + height + ); + }); + } + + private trackWindowState(id: string, window: BrowserWindow): void { + const saveState = (): void => { + // Debounce state updates + const existing = this.stateUpdateDebounce.get(id); + if (existing) { + clearTimeout(existing); + } + + this.stateUpdateDebounce.set( + id, + setTimeout(() => { + if (window.isDestroyed()) return; + + const bounds = window.getBounds(); + this.saveWindowState(id, { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: window.isMaximized(), + isFullScreen: window.isFullScreen(), + }); + }, 500), + ); + }; + + window.on("resize", saveState); + window.on("move", saveState); + window.on("maximize", saveState); + window.on("unmaximize", saveState); + window.on("enter-full-screen", saveState); + window.on("leave-full-screen", saveState); + + // Save on close + window.on("close", () => { + if (!window.isDestroyed()) { + const bounds = window.getBounds(); + this.saveWindowState(id, { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: window.isMaximized(), + isFullScreen: window.isFullScreen(), + }); + } + }); + } +} + +export const windowManager = new WindowManager(); +``` + +--- + +## Secure File Operations + +### File Service with Validation + +```typescript +// src/main/services/file-service.ts +import { + readFile, + writeFile, + access, + mkdir, + stat, + readdir, + unlink, + rename, +} from "fs/promises"; +import { constants, createReadStream, createWriteStream } from "fs"; +import { join, dirname, basename, extname, normalize, resolve } from "path"; +import { app } from "electron"; +import { z } from "zod"; +import { pipeline } from "stream/promises"; +import { createHash } from "crypto"; + +// Validation schemas +const SafePathSchema = z + .string() + .min(1) + .max(4096) + .refine( + (path) => { + const normalized = normalize(path); + // Prevent path traversal + return !normalized.includes("..") && !normalized.includes("\0"); + }, + { message: "Invalid path: potential path traversal detected" }, + ); + +const FileNameSchema = z + .string() + .min(1) + .max(255) + .regex(/^[^<>:"/\\|?*\x00-\x1f]+$/, { + message: "Invalid filename: contains reserved characters", + }); + +export interface FileResult { + success: boolean; + data?: T; + error?: string; +} + +export interface FileMetadata { + name: string; + path: string; + size: number; + isDirectory: boolean; + created: Date; + modified: Date; + extension: string; +} + +export class FileService { + private allowedDirectories: string[]; + + constructor() { + // Define allowed directories for file operations + this.allowedDirectories = [ + app.getPath("documents"), + app.getPath("downloads"), + app.getPath("userData"), + app.getPath("temp"), + ]; + } + + async read(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + const content = await readFile(validPath, "utf-8"); + return { success: true, data: content }; + } catch (error) { + return this.handleError(error); + } + } + + async readBinary(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + const content = await readFile(validPath); + return { success: true, data: content }; + } catch (error) { + return this.handleError(error); + } + } + + async write( + filePath: string, + content: string | Buffer, + ): Promise> { + try { + const validPath = this.validatePath(filePath); + + // Ensure directory exists + await mkdir(dirname(validPath), { recursive: true }); + + await writeFile(validPath, content); + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async exists(filePath: string): Promise { + try { + const validPath = this.validatePath(filePath); + await access(validPath, constants.F_OK); + return true; + } catch { + return false; + } + } + + async getMetadata(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + const stats = await stat(validPath); + + return { + success: true, + data: { + name: basename(validPath), + path: validPath, + size: stats.size, + isDirectory: stats.isDirectory(), + created: stats.birthtime, + modified: stats.mtime, + extension: extname(validPath), + }, + }; + } catch (error) { + return this.handleError(error); + } + } + + async listDirectory(dirPath: string): Promise> { + try { + const validPath = this.validatePath(dirPath); + const entries = await readdir(validPath, { withFileTypes: true }); + + const metadata: FileMetadata[] = await Promise.all( + entries.map(async (entry) => { + const entryPath = join(validPath, entry.name); + const stats = await stat(entryPath); + + return { + name: entry.name, + path: entryPath, + size: stats.size, + isDirectory: entry.isDirectory(), + created: stats.birthtime, + modified: stats.mtime, + extension: entry.isDirectory() ? "" : extname(entry.name), + }; + }), + ); + + return { success: true, data: metadata }; + } catch (error) { + return this.handleError(error); + } + } + + async delete(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + await unlink(validPath); + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async move(sourcePath: string, destPath: string): Promise> { + try { + const validSource = this.validatePath(sourcePath); + const validDest = this.validatePath(destPath); + + await mkdir(dirname(validDest), { recursive: true }); + await rename(validSource, validDest); + + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async copy(sourcePath: string, destPath: string): Promise> { + try { + const validSource = this.validatePath(sourcePath); + const validDest = this.validatePath(destPath); + + await mkdir(dirname(validDest), { recursive: true }); + + await pipeline( + createReadStream(validSource), + createWriteStream(validDest), + ); + + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async getHash( + filePath: string, + algorithm: "md5" | "sha256" = "sha256", + ): Promise> { + try { + const validPath = this.validatePath(filePath); + const hash = createHash(algorithm); + const stream = createReadStream(validPath); + + return new Promise((resolve) => { + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => { + resolve({ success: true, data: hash.digest("hex") }); + }); + stream.on("error", (error) => { + resolve(this.handleError(error)); + }); + }); + } catch (error) { + return this.handleError(error); + } + } + + private validatePath(filePath: string): string { + // Validate path format + const safePath = SafePathSchema.parse(filePath); + + // Resolve to absolute path + const absolutePath = resolve(safePath); + + // Validate filename if applicable + const fileName = basename(absolutePath); + if (fileName && !fileName.startsWith(".")) { + FileNameSchema.parse(fileName); + } + + // Optional: Check if path is within allowed directories + // Uncomment if you want to restrict file access + // this.validateAllowedDirectory(absolutePath); + + return absolutePath; + } + + private validateAllowedDirectory(absolutePath: string): void { + const isAllowed = this.allowedDirectories.some((dir) => + absolutePath.startsWith(dir), + ); + + if (!isAllowed) { + throw new Error(`Access denied: path is outside allowed directories`); + } + } + + private handleError(error: unknown): FileResult { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { success: false, error: message }; + } +} + +export const fileService = new FileService(); +``` + +--- + +## React Renderer Integration + +### Electron API Hook + +```typescript +// src/renderer/src/hooks/useElectron.ts +import { useEffect, useState, useCallback } from "react"; + +// Type from preload +type ElectronAPI = Window["electronAPI"]; + +export function useElectron(): ElectronAPI { + if (!window.electronAPI) { + throw new Error("Electron API not available. Are you running in Electron?"); + } + return window.electronAPI; +} + +export function useWindowMaximized(): boolean { + const [isMaximized, setIsMaximized] = useState(false); + const electron = useElectron(); + + useEffect(() => { + // Get initial state + electron.window.isMaximized().then(setIsMaximized); + + // Subscribe to changes + const unsubscribe = electron.window.onMaximizeChange(setIsMaximized); + return unsubscribe; + }, [electron]); + + return isMaximized; +} + +export function useAutoUpdate() { + const electron = useElectron(); + const [updateAvailable, setUpdateAvailable] = useState<{ + version: string; + releaseNotes: string; + } | null>(null); + const [downloadProgress, setDownloadProgress] = useState<{ + percent: number; + transferred: number; + total: number; + } | null>(null); + const [updateReady, setUpdateReady] = useState(false); + + useEffect(() => { + const unsubAvailable = electron.update.onAvailable((info) => { + setUpdateAvailable(info); + }); + + const unsubProgress = electron.update.onProgress((progress) => { + setDownloadProgress(progress); + }); + + const unsubDownloaded = electron.update.onDownloaded(() => { + setUpdateReady(true); + setDownloadProgress(null); + }); + + return () => { + unsubAvailable(); + unsubProgress(); + unsubDownloaded(); + }; + }, [electron]); + + const checkForUpdates = useCallback(() => { + electron.update.check(); + }, [electron]); + + const downloadUpdate = useCallback(() => { + electron.update.download(); + }, [electron]); + + const installUpdate = useCallback(() => { + electron.update.install(); + }, [electron]); + + return { + updateAvailable, + downloadProgress, + updateReady, + checkForUpdates, + downloadUpdate, + installUpdate, + }; +} + +export function useStorage( + key: string, + defaultValue: T, +): [T, (value: T) => Promise, boolean] { + const electron = useElectron(); + const [value, setValue] = useState(defaultValue); + const [loading, setLoading] = useState(true); + + useEffect(() => { + electron.storage.get(key).then((stored) => { + if (stored !== undefined) { + setValue(stored); + } + setLoading(false); + }); + }, [electron, key]); + + const updateValue = useCallback( + async (newValue: T) => { + setValue(newValue); + await electron.storage.set(key, newValue); + }, + [electron, key], + ); + + return [value, updateValue, loading]; +} +``` + +### Custom Title Bar Component + +```tsx +// src/renderer/src/components/TitleBar.tsx +import { useElectron, useWindowMaximized } from "../hooks/useElectron"; +import styles from "./TitleBar.module.css"; + +interface TitleBarProps { + title?: string; +} + +export function TitleBar({ title = "My App" }: TitleBarProps) { + const electron = useElectron(); + const isMaximized = useWindowMaximized(); + + return ( +
+ {/* Drag region */} +
+ {title} +
+ + {/* Window controls */} +
+ {!electron.platform.isMac && ( + <> + + + + + )} +
+
+ ); +} + +// Icon components +function MinimizeIcon() { + return ( + + + + ); +} + +function MaximizeIcon() { + return ( + + + + ); +} + +function RestoreIcon() { + return ( + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} +``` + +```css +/* src/renderer/src/components/TitleBar.module.css */ +.titleBar { + display: flex; + height: 32px; + background: var(--titlebar-bg, #2d2d2d); + color: var(--titlebar-color, #ffffff); + user-select: none; +} + +.dragRegion { + flex: 1; + display: flex; + align-items: center; + padding-left: 12px; + -webkit-app-region: drag; +} + +.title { + font-size: 12px; + font-weight: 500; +} + +.windowControls { + display: flex; + -webkit-app-region: no-drag; +} + +.controlButton { + width: 46px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + transition: background-color 0.1s; +} + +.controlButton:hover { + background: rgba(255, 255, 255, 0.1); +} + +.controlButton:active { + background: rgba(255, 255, 255, 0.2); +} + +.closeButton:hover { + background: #e81123; +} +``` + +--- + +## Testing with Playwright + +### E2E Test Setup + +```typescript +// e2e/electron.spec.ts +import { test, expect, _electron as electron } from "@playwright/test"; +import type { ElectronApplication, Page } from "@playwright/test"; + +let app: ElectronApplication; +let page: Page; + +test.beforeAll(async () => { + // Launch Electron app + app = await electron.launch({ + args: ["."], + env: { + ...process.env, + NODE_ENV: "test", + }, + }); + + // Get the first window + page = await app.firstWindow(); + + // Wait for app to be ready + await page.waitForLoadState("domcontentloaded"); +}); + +test.afterAll(async () => { + await app.close(); +}); + +test.describe("Main Window", () => { + test("should display title", async () => { + const title = await page.title(); + expect(title).toBe("My App"); + }); + + test("should have correct dimensions", async () => { + const { width, height } = page.viewportSize()!; + expect(width).toBeGreaterThanOrEqual(800); + expect(height).toBeGreaterThanOrEqual(600); + }); + + test("should show main content", async () => { + await expect(page.locator("#app")).toBeVisible(); + }); +}); + +test.describe("Window Controls", () => { + test("should minimize window", async () => { + // Click minimize button + await page.click('[aria-label="Minimize"]'); + + // Verify window is minimized + const isMinimized = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.isMinimized(); + }); + + expect(isMinimized).toBe(true); + + // Restore for next tests + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + window.restore(); + }); + }); + + test("should maximize/restore window", async () => { + // Click maximize button + await page.click('[aria-label="Maximize"]'); + + // Verify window is maximized + let isMaximized = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.isMaximized(); + }); + + expect(isMaximized).toBe(true); + + // Click restore button + await page.click('[aria-label="Restore"]'); + + // Verify window is not maximized + isMaximized = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.isMaximized(); + }); + + expect(isMaximized).toBe(false); + }); +}); + +test.describe("IPC Communication", () => { + test("should get app version", async () => { + const version = await page.evaluate(async () => { + return window.electronAPI.app.getVersion(); + }); + + expect(version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + test("should access storage", async () => { + // Set value + await page.evaluate(async () => { + await window.electronAPI.storage.set("test-key", { foo: "bar" }); + }); + + // Get value + const value = await page.evaluate(async () => { + return window.electronAPI.storage.get("test-key"); + }); + + expect(value).toEqual({ foo: "bar" }); + + // Clean up + await page.evaluate(async () => { + await window.electronAPI.storage.delete("test-key"); + }); + }); +}); + +test.describe("File Operations", () => { + test("should check if file exists", async () => { + const exists = await page.evaluate(async () => { + return window.electronAPI.file.exists("package.json"); + }); + + expect(exists).toBe(true); + }); +}); +``` + +### Playwright Configuration + +```typescript +// playwright.config.ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30000, + expect: { + timeout: 5000, + }, + fullyParallel: false, // Electron tests should run sequentially + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: "html", + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + }, +}); +``` + +--- + +Version: 1.1.0 +Last Updated: 2026-01-10 +Changes: Aligned with SKILL.md v1.1.0 updates diff --git a/.agent/skills/moai-framework-electron/reference.md b/.agent/skills/moai-framework-electron/reference.md new file mode 100644 index 0000000..6ed352a --- /dev/null +++ b/.agent/skills/moai-framework-electron/reference.md @@ -0,0 +1,1649 @@ +# Electron Framework Reference Guide + +## Platform Version Matrix + +### Electron 33 (October 2024) - Current Stable + +- Chromium: 130 +- Node.js: 20.18.0 +- V8: 13.0 +- Key Features: + - Enhanced security defaults with sandbox enabled by default + - Improved context isolation patterns + - Native ESM support in main process + - Service Worker support in renderer + - WebGPU API support for GPU-accelerated graphics + - Improved auto-updater with differential updates + - Utility process enhancements for background tasks + - Better crash reporting integration + +### Electron 32 (August 2024) + +- Chromium: 128 +- Node.js: 20.16.0 +- Key Features: + - Utility process improvements + - Enhanced file system access + - Better macOS notarization support + - Improved window management APIs + +### Electron 31 (June 2024) + +- Chromium: 126 +- Node.js: 20.14.0 +- Key Features: + - Performance improvements for large apps + - Enhanced IPC serialization + - Better TypeScript support + +## Context7 Library Mappings + +### Core Framework + +``` +/electron/electron - Electron framework +/electron/forge - Electron Forge tooling +/electron-userland/electron-builder - App packaging +``` + +### Build Tools + +``` +/nickmeinhold/electron-vite - Vite integration +/nickmeinhold/electron-esbuild - esbuild integration +``` + +### Native Modules + +``` +/nickmeinhold/better-sqlite3 - SQLite database +/nickmeinhold/keytar - Secure credential storage +/nickmeinhold/node-pty - Terminal emulation +``` + +### Auto-Update + +``` +/electron-userland/electron-updater - Auto-update support +``` + +### Testing + +``` +/nickmeinhold/spectron - E2E testing (deprecated) +/nickmeinhold/playwright - Modern E2E testing +``` + +--- + +## Architecture Patterns + +### Process Model + +Electron Process Architecture: + +``` + ┌─────────────────────────────────────┐ + │ Main Process │ + │ - Single instance per app │ + │ - Full Node.js access │ + │ - Creates BrowserWindows │ + │ - Manages app lifecycle │ + │ - Native OS integration │ + └─────────────┬───────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ Renderer Process │ │ Renderer Process │ │ Utility Process │ +│ - Web content │ │ - Web content │ │ - Background │ +│ - Sandboxed │ │ - Sandboxed │ │ tasks │ +│ - No Node.js │ │ - No Node.js │ │ - Node.js access │ +│ (default) │ │ (default) │ │ - No GUI │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ +``` + +### Recommended Project Structure + +Directory Layout: + +``` +electron-app/ +├── src/ +│ ├── main/ # Main process code +│ │ ├── index.ts # Entry point +│ │ ├── app.ts # App lifecycle +│ │ ├── ipc/ # IPC handlers +│ │ │ ├── index.ts +│ │ │ ├── file-handlers.ts +│ │ │ └── window-handlers.ts +│ │ ├── services/ # Business logic +│ │ │ ├── storage.ts +│ │ │ └── updater.ts +│ │ └── windows/ # Window management +│ │ ├── main-window.ts +│ │ └── settings-window.ts +│ ├── preload/ # Preload scripts +│ │ ├── index.ts # Main preload +│ │ └── api.ts # Exposed APIs +│ ├── renderer/ # React/Vue/Svelte app +│ │ ├── src/ +│ │ ├── index.html +│ │ └── vite.config.ts +│ └── shared/ # Shared types/constants +│ ├── types.ts +│ └── constants.ts +├── resources/ # App resources +│ ├── icons/ +│ └── locales/ +├── electron.vite.config.ts +├── electron-builder.yml +└── package.json +``` + +--- + +## Main Process APIs + +### App Lifecycle + +```typescript +// src/main/app.ts +import { app, BrowserWindow, session } from "electron"; +import { join } from "path"; + +class Application { + private mainWindow: BrowserWindow | null = null; + + async initialize(): Promise { + // Set app user model ID for Windows + if (process.platform === "win32") { + app.setAppUserModelId(app.getName()); + } + + // Prevent multiple instances + const gotSingleLock = app.requestSingleInstanceLock(); + if (!gotSingleLock) { + app.quit(); + return; + } + + app.on("second-instance", () => { + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + } + }); + + // macOS: Re-create window when dock icon clicked + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + this.createMainWindow(); + } + }); + + // Quit when all windows closed (except macOS) + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + // Wait for ready + await app.whenReady(); + + // Configure session + this.configureSession(); + + // Create main window + this.createMainWindow(); + } + + private configureSession(): void { + // Configure Content Security Policy + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'", + ], + }, + }); + }); + + // Configure permissions + session.defaultSession.setPermissionRequestHandler( + (webContents, permission, callback) => { + const allowedPermissions = ["notifications", "clipboard-read"]; + callback(allowedPermissions.includes(permission)); + }, + ); + } + + private createMainWindow(): void { + this.mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + show: false, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webSecurity: true, + }, + }); + + // Show window when ready + this.mainWindow.on("ready-to-show", () => { + this.mainWindow?.show(); + }); + + // Load app content + if (process.env.NODE_ENV === "development") { + this.mainWindow.loadURL("http://localhost:5173"); + this.mainWindow.webContents.openDevTools(); + } else { + this.mainWindow.loadFile(join(__dirname, "../renderer/index.html")); + } + } +} + +export const application = new Application(); +``` + +### Window Management + +```typescript +// src/main/windows/window-manager.ts +import { + BrowserWindow, + BrowserWindowConstructorOptions, + screen, +} from "electron"; +import { join } from "path"; + +interface WindowState { + width: number; + height: number; + x?: number; + y?: number; + isMaximized: boolean; +} + +export class WindowManager { + private windows = new Map(); + private stateStore: Map = new Map(); + + createWindow( + id: string, + options: BrowserWindowConstructorOptions = {}, + ): BrowserWindow { + // Restore previous state + const savedState = this.stateStore.get(id); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + + const defaultOptions: BrowserWindowConstructorOptions = { + width: savedState?.width ?? Math.floor(width * 0.8), + height: savedState?.height ?? Math.floor(height * 0.8), + x: savedState?.x, + y: savedState?.y, + show: false, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }; + + const window = new BrowserWindow({ + ...defaultOptions, + ...options, + webPreferences: { + ...defaultOptions.webPreferences, + ...options.webPreferences, + }, + }); + + // Restore maximized state + if (savedState?.isMaximized) { + window.maximize(); + } + + // Save state on close + window.on("close", () => { + this.saveWindowState(id, window); + }); + + window.on("closed", () => { + this.windows.delete(id); + }); + + this.windows.set(id, window); + return window; + } + + getWindow(id: string): BrowserWindow | undefined { + return this.windows.get(id); + } + + closeWindow(id: string): void { + const window = this.windows.get(id); + if (window && !window.isDestroyed()) { + window.close(); + } + } + + private saveWindowState(id: string, window: BrowserWindow): void { + const bounds = window.getBounds(); + this.stateStore.set(id, { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: window.isMaximized(), + }); + } +} + +export const windowManager = new WindowManager(); +``` + +--- + +## IPC Communication + +### Type-Safe IPC Pattern + +```typescript +// src/shared/ipc-types.ts +export interface IpcChannels { + // Main -> Renderer + "app:update-available": { version: string }; + "app:update-downloaded": void; + + // Renderer -> Main (invoke) + "file:open": { path: string }; + "file:save": { path: string; content: string }; + "file:read": string; // Returns file content + "window:minimize": void; + "window:maximize": void; + "window:close": void; + "storage:get": { key: string }; + "storage:set": { key: string; value: unknown }; +} + +export type IpcChannel = keyof IpcChannels; +export type IpcPayload = IpcChannels[C]; +``` + +### Main Process Handlers + +```typescript +// src/main/ipc/index.ts +import { ipcMain, dialog, BrowserWindow } from "electron"; +import { readFile, writeFile } from "fs/promises"; +import Store from "electron-store"; + +const store = new Store(); + +export function registerIpcHandlers(): void { + // File operations + ipcMain.handle("file:open", async () => { + const result = await dialog.showOpenDialog({ + properties: ["openFile"], + filters: [ + { name: "All Files", extensions: ["*"] }, + { name: "Text", extensions: ["txt", "md"] }, + ], + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + const filePath = result.filePaths[0]; + const content = await readFile(filePath, "utf-8"); + return { path: filePath, content }; + }); + + ipcMain.handle( + "file:save", + async (_event, { path, content }: { path: string; content: string }) => { + await writeFile(path, content, "utf-8"); + return { success: true }; + }, + ); + + ipcMain.handle("file:read", async (_event, path: string) => { + return readFile(path, "utf-8"); + }); + + // Window operations + ipcMain.handle("window:minimize", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + window?.minimize(); + }); + + ipcMain.handle("window:maximize", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window?.isMaximized()) { + window.unmaximize(); + } else { + window?.maximize(); + } + }); + + ipcMain.handle("window:close", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + window?.close(); + }); + + // Storage operations + ipcMain.handle("storage:get", (_event, { key }: { key: string }) => { + return store.get(key); + }); + + ipcMain.handle( + "storage:set", + (_event, { key, value }: { key: string; value: unknown }) => { + store.set(key, value); + return { success: true }; + }, + ); +} +``` + +### Preload Script + +```typescript +// src/preload/index.ts +import { contextBridge, ipcRenderer } from "electron"; + +// Expose protected methods for renderer +const api = { + // Window controls + window: { + minimize: () => ipcRenderer.invoke("window:minimize"), + maximize: () => ipcRenderer.invoke("window:maximize"), + close: () => ipcRenderer.invoke("window:close"), + }, + + // File operations + file: { + open: () => ipcRenderer.invoke("file:open"), + save: (path: string, content: string) => + ipcRenderer.invoke("file:save", { path, content }), + read: (path: string) => ipcRenderer.invoke("file:read", path), + }, + + // Storage + storage: { + get: (key: string): Promise => + ipcRenderer.invoke("storage:get", { key }), + set: (key: string, value: unknown) => + ipcRenderer.invoke("storage:set", { key, value }), + }, + + // App events + onUpdateAvailable: (callback: (version: string) => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + { version }: { version: string }, + ) => { + callback(version); + }; + ipcRenderer.on("app:update-available", handler); + return () => ipcRenderer.removeListener("app:update-available", handler); + }, + + onUpdateDownloaded: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on("app:update-downloaded", handler); + return () => ipcRenderer.removeListener("app:update-downloaded", handler); + }, +}; + +contextBridge.exposeInMainWorld("electronAPI", api); + +// Type declaration for renderer +declare global { + interface Window { + electronAPI: typeof api; + } +} +``` + +--- + +## Auto-Update + +### Update Service + +```typescript +// src/main/services/updater.ts +import { autoUpdater, UpdateInfo } from "electron-updater"; +import { BrowserWindow, dialog } from "electron"; +import log from "electron-log"; + +export class UpdateService { + private mainWindow: BrowserWindow | null = null; + + initialize(window: BrowserWindow): void { + this.mainWindow = window; + + // Configure logging + autoUpdater.logger = log; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + + // Event handlers + autoUpdater.on("checking-for-update", () => { + log.info("Checking for updates..."); + }); + + autoUpdater.on("update-available", (info: UpdateInfo) => { + log.info("Update available:", info.version); + this.mainWindow?.webContents.send("app:update-available", { + version: info.version, + }); + this.promptForUpdate(info); + }); + + autoUpdater.on("update-not-available", () => { + log.info("No updates available"); + }); + + autoUpdater.on("error", (error) => { + log.error("Update error:", error); + }); + + autoUpdater.on("download-progress", (progress) => { + log.info(`Download progress: ${progress.percent.toFixed(1)}%`); + }); + + autoUpdater.on("update-downloaded", () => { + log.info("Update downloaded"); + this.mainWindow?.webContents.send("app:update-downloaded"); + this.promptForRestart(); + }); + } + + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + log.error("Failed to check for updates:", error); + } + } + + private async promptForUpdate(info: UpdateInfo): Promise { + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Available", + message: `Version ${info.version} is available. Would you like to download it?`, + buttons: ["Download", "Later"], + }); + + if (result.response === 0) { + autoUpdater.downloadUpdate(); + } + } + + private async promptForRestart(): Promise { + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Ready", + message: + "A new version has been downloaded. Restart to apply the update?", + buttons: ["Restart Now", "Later"], + }); + + if (result.response === 0) { + autoUpdater.quitAndInstall(false, true); + } + } +} + +export const updateService = new UpdateService(); +``` + +--- + +## Security Best Practices + +### Security Checklist + +Mandatory Security Settings: + +- contextIsolation: true (always enable) +- nodeIntegration: false (never enable in renderer) +- sandbox: true (always enable) +- webSecurity: true (never disable) + +IPC Security Rules: + +- Validate all inputs from renderer +- Never expose Node.js APIs directly +- Use invoke/handle pattern (not send/on for sensitive operations) +- Whitelist allowed operations + +Content Security Policy: + +- Restrict script sources to 'self' +- Disable unsafe-inline for scripts +- Use nonce or hash for inline scripts if needed + +### Input Validation + +```typescript +// src/main/ipc/validators.ts +import { z } from "zod"; + +const FilePathSchema = z.string().refine( + (path) => { + // Prevent path traversal + const normalized = path.replace(/\\/g, "/"); + return !normalized.includes("..") && !normalized.startsWith("/"); + }, + { message: "Invalid file path" }, +); + +const StorageKeySchema = z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9_.-]+$/); + +export const validators = { + filePath: (path: unknown) => FilePathSchema.parse(path), + storageKey: (key: unknown) => StorageKeySchema.parse(key), +}; +``` + +--- + +## Native Integration + +### System Tray + +```typescript +// src/main/services/tray.ts +import { Tray, Menu, app, nativeImage } from "electron"; +import { join } from "path"; + +export class TrayService { + private tray: Tray | null = null; + + initialize(): void { + const iconPath = join(__dirname, "../../resources/icons/tray.png"); + const icon = nativeImage.createFromPath(iconPath); + + this.tray = new Tray(icon); + this.tray.setToolTip(app.getName()); + + const contextMenu = Menu.buildFromTemplate([ + { + label: "Show App", + click: () => { + const { windowManager } = require("./window-manager"); + const mainWindow = windowManager.getWindow("main"); + mainWindow?.show(); + mainWindow?.focus(); + }, + }, + { type: "separator" }, + { + label: "Preferences", + accelerator: "CmdOrCtrl+,", + click: () => { + // Open preferences + }, + }, + { type: "separator" }, + { + label: "Quit", + accelerator: "CmdOrCtrl+Q", + click: () => app.quit(), + }, + ]); + + this.tray.setContextMenu(contextMenu); + + // macOS: Click to show app + this.tray.on("click", () => { + const { windowManager } = require("./window-manager"); + const mainWindow = windowManager.getWindow("main"); + mainWindow?.show(); + mainWindow?.focus(); + }); + } + + destroy(): void { + this.tray?.destroy(); + this.tray = null; + } +} + +export const trayService = new TrayService(); +``` + +### Native Menu + +```typescript +// src/main/services/menu.ts +import { Menu, app, shell, MenuItemConstructorOptions } from "electron"; + +export function createApplicationMenu(): void { + const isMac = process.platform === "darwin"; + + const template: MenuItemConstructorOptions[] = [ + // App menu (macOS only) + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + { role: "services" as const }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + }, + ] + : []), + + // File menu + { + label: "File", + submenu: [ + { + label: "New", + accelerator: "CmdOrCtrl+N", + click: () => { + // Handle new file + }, + }, + { + label: "Open...", + accelerator: "CmdOrCtrl+O", + click: () => { + // Handle open + }, + }, + { type: "separator" }, + { + label: "Save", + accelerator: "CmdOrCtrl+S", + click: () => { + // Handle save + }, + }, + { type: "separator" }, + isMac ? { role: "close" } : { role: "quit" }, + ], + }, + + // Edit menu + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + + // View menu + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + + // Help menu + { + label: "Help", + submenu: [ + { + label: "Documentation", + click: () => shell.openExternal("https://docs.example.com"), + }, + { + label: "Report Issue", + click: () => + shell.openExternal("https://github.com/example/repo/issues"), + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} +``` + +--- + +## Configuration + +### Electron Forge Configuration + +```javascript +// forge.config.js +module.exports = { + packagerConfig: { + asar: true, + darwinDarkModeSupport: true, + executableName: "my-app", + appBundleId: "com.example.myapp", + appCategoryType: "public.app-category.developer-tools", + icon: "./resources/icons/icon", + osxSign: { + identity: "Developer ID Application: Your Name (TEAM_ID)", + "hardened-runtime": true, + entitlements: "./entitlements.plist", + "entitlements-inherit": "./entitlements.plist", + "signature-flags": "library", + }, + osxNotarize: { + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }, + }, + rebuildConfig: {}, + makers: [ + { + name: "@electron-forge/maker-squirrel", + config: { + name: "my_app", + setupIcon: "./resources/icons/icon.ico", + }, + }, + { + name: "@electron-forge/maker-zip", + platforms: ["darwin"], + }, + { + name: "@electron-forge/maker-dmg", + config: { + icon: "./resources/icons/icon.icns", + format: "ULFO", + }, + }, + { + name: "@electron-forge/maker-deb", + config: { + options: { + maintainer: "Your Name", + homepage: "https://example.com", + }, + }, + }, + { + name: "@electron-forge/maker-rpm", + config: {}, + }, + ], + plugins: [ + { + name: "@electron-forge/plugin-vite", + config: { + build: [ + { + entry: "src/main/index.ts", + config: "vite.main.config.ts", + }, + { + entry: "src/preload/index.ts", + config: "vite.preload.config.ts", + }, + ], + renderer: [ + { + name: "main_window", + config: "vite.renderer.config.ts", + }, + ], + }, + }, + ], + publishers: [ + { + name: "@electron-forge/publisher-github", + config: { + repository: { + owner: "your-username", + name: "your-repo", + }, + prerelease: false, + }, + }, + ], +}; +``` + +### Electron Builder Configuration + +```yaml +# electron-builder.yml +appId: com.example.myapp +productName: My App +copyright: Copyright (c) 2025 Your Name + +directories: + output: dist + buildResources: resources + +files: + - "!**/.vscode/*" + - "!src/*" + - "!docs/*" + - "!*.md" + +extraResources: + - from: resources/ + to: resources/ + filter: + - "**/*" + +asar: true +compression: maximum + +mac: + category: public.app-category.developer-tools + icon: resources/icons/icon.icns + hardenedRuntime: true + gatekeeperAssess: false + entitlements: entitlements.mac.plist + entitlementsInherit: entitlements.mac.plist + notarize: + teamId: ${APPLE_TEAM_ID} + target: + - target: dmg + arch: [x64, arm64] + - target: zip + arch: [x64, arm64] + +dmg: + sign: false + contents: + - x: 130 + y: 220 + - x: 410 + y: 220 + type: link + path: /Applications + +win: + icon: resources/icons/icon.ico + signingHashAlgorithms: [sha256] + signAndEditExecutable: true + target: + - target: nsis + arch: [x64] + - target: portable + arch: [x64] + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + installerIcon: resources/icons/icon.ico + uninstallerIcon: resources/icons/icon.ico + installerHeaderIcon: resources/icons/icon.ico + createDesktopShortcut: true + createStartMenuShortcut: true + +linux: + icon: resources/icons + category: Development + target: + - target: AppImage + arch: [x64] + - target: deb + arch: [x64] + - target: rpm + arch: [x64] + +publish: + provider: github + owner: your-username + repo: your-repo +``` + +--- + +## Utility Process (Electron 33+) + +### Background Task Worker + +Utility processes run in a separate Node.js environment for CPU-intensive tasks without blocking the main process: + +```typescript +// src/main/workers/utility-worker.ts +import { utilityProcess, MessageChannelMain } from "electron"; +import { join } from "path"; + +export class UtilityWorker { + private worker: Electron.UtilityProcess | null = null; + private port: Electron.MessagePortMain | null = null; + + async spawn(): Promise { + const { port1, port2 } = new MessageChannelMain(); + + this.worker = utilityProcess.fork( + join(__dirname, "workers/image-processor.js"), + [], + { + serviceName: "image-processor", + allowLoadingUnsignedLibraries: false, + } + ); + + this.worker.postMessage({ type: "init" }, [port1]); + this.port = port2; + + this.worker.on("exit", (code) => { + console.log(`Utility process exited with code ${code}`); + this.worker = null; + }); + } + + async processImage(imagePath: string): Promise { + return new Promise((resolve, reject) => { + if (!this.port) { + reject(new Error("Worker not initialized")); + return; + } + + const handler = (event: Electron.MessageEvent) => { + if (event.data.type === "result") { + this.port?.removeListener("message", handler); + resolve(Buffer.from(event.data.buffer)); + } else if (event.data.type === "error") { + this.port?.removeListener("message", handler); + reject(new Error(event.data.message)); + } + }; + + this.port.on("message", handler); + this.port.postMessage({ type: "process", path: imagePath }); + }); + } + + terminate(): void { + this.worker?.kill(); + this.worker = null; + this.port = null; + } +} +``` + +### Utility Process Script + +```typescript +// src/main/workers/image-processor.js +const sharp = require("sharp"); + +process.parentPort.on("message", async (event) => { + const [port] = event.ports; + + port.on("message", async (msgEvent) => { + const { type, path } = msgEvent.data; + + if (type === "process") { + try { + const buffer = await sharp(path) + .resize(800, 600, { fit: "inside" }) + .webp({ quality: 80 }) + .toBuffer(); + + port.postMessage({ type: "result", buffer }); + } catch (error) { + port.postMessage({ type: "error", message: error.message }); + } + } + }); + + port.start(); +}); +``` + +--- + +## Protocol Handlers and Deep Linking + +### Custom Protocol Registration + +```typescript +// src/main/protocol.ts +import { app, protocol, net } from "electron"; +import { join } from "path"; +import { pathToFileURL } from "url"; + +const PROTOCOL_NAME = "myapp"; + +export function registerProtocols(): void { + // Register as default protocol client (for deep linking) + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [ + join(process.argv[1]), + ]); + } + } else { + app.setAsDefaultProtocolClient(PROTOCOL_NAME); + } + + // Register custom protocol handler for local resources + protocol.handle("app", (request) => { + const url = new URL(request.url); + const filePath = join(__dirname, "../renderer", url.pathname); + return net.fetch(pathToFileURL(filePath).toString()); + }); +} + +export function handleProtocolUrl(url: string): void { + // Parse the URL: myapp://action/path?query=value + const parsedUrl = new URL(url); + + switch (parsedUrl.hostname) { + case "open": + handleOpenAction(parsedUrl.pathname, parsedUrl.searchParams); + break; + case "auth": + handleAuthCallback(parsedUrl.searchParams); + break; + default: + console.warn("Unknown protocol action:", parsedUrl.hostname); + } +} + +function handleOpenAction( + path: string, + params: URLSearchParams +): void { + // Handle open file/project action + const filePath = decodeURIComponent(path.slice(1)); + // Send to renderer or process directly +} + +function handleAuthCallback(params: URLSearchParams): void { + // Handle OAuth callback + const code = params.get("code"); + const state = params.get("state"); + // Process authentication +} +``` + +### macOS Deep Link Handling + +```typescript +// src/main/index.ts +app.on("open-url", (event, url) => { + event.preventDefault(); + handleProtocolUrl(url); +}); + +// Handle deep link on Windows/Linux (second instance) +app.on("second-instance", (_event, commandLine) => { + const url = commandLine.find((arg) => arg.startsWith(`${PROTOCOL_NAME}://`)); + if (url) { + handleProtocolUrl(url); + } + + // Focus the main window + const mainWindow = windowManager.getWindow("main"); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } +}); +``` + +--- + +## Security Hardening (OWASP Aligned) + +### Comprehensive Security Configuration + +```typescript +// src/main/security.ts +import { app, session, shell, BrowserWindow } from "electron"; + +export function configureSecurity(): void { + // 1. Disable remote module (deprecated but ensure disabled) + app.on("remote-get-builtin", (event) => event.preventDefault()); + app.on("remote-get-current-web-contents", (event) => event.preventDefault()); + app.on("remote-get-current-window", (event) => event.preventDefault()); + + // 2. Block navigation to untrusted origins + app.on("web-contents-created", (_event, contents) => { + // Block navigation + contents.on("will-navigate", (event, url) => { + const allowedOrigins = ["http://localhost", "file://"]; + const isAllowed = allowedOrigins.some((origin) => url.startsWith(origin)); + if (!isAllowed) { + event.preventDefault(); + console.warn("Blocked navigation to:", url); + } + }); + + // Block new windows + contents.setWindowOpenHandler(({ url }) => { + // Open external URLs in default browser + if (url.startsWith("https://") || url.startsWith("http://")) { + shell.openExternal(url); + } + return { action: "deny" }; + }); + + // Block webview creation + contents.on("will-attach-webview", (event) => { + event.preventDefault(); + console.warn("Blocked webview creation"); + }); + }); +} + +export function configureSessionSecurity(): void { + const ses = session.defaultSession; + + // Content Security Policy + ses.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self' https://api.example.com", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + ], + "X-Content-Type-Options": ["nosniff"], + "X-Frame-Options": ["DENY"], + "X-XSS-Protection": ["1; mode=block"], + }, + }); + }); + + // Permission request handler + ses.setPermissionRequestHandler((webContents, permission, callback) => { + const allowedPermissions: Electron.PermissionType[] = [ + "notifications", + "clipboard-read", + ]; + + const denied: Electron.PermissionType[] = [ + "geolocation", + "media", + "mediaKeySystem", + "midi", + "pointerLock", + "fullscreen", + ]; + + if (denied.includes(permission)) { + console.warn(`Denied permission: ${permission}`); + callback(false); + return; + } + + callback(allowedPermissions.includes(permission)); + }); + + // Certificate error handler (for development, not production) + if (process.env.NODE_ENV !== "development") { + ses.setCertificateVerifyProc((request, callback) => { + // Reject invalid certificates in production + if (request.errorCode !== 0) { + console.error("Certificate error:", request.hostname); + callback(-2); // Reject + return; + } + callback(0); // Accept + }); + } +} +``` + +### Secure BrowserWindow Factory + +```typescript +// src/main/windows/secure-window.ts +import { BrowserWindow, BrowserWindowConstructorOptions } from "electron"; +import { join } from "path"; + +export function createSecureWindow( + options: BrowserWindowConstructorOptions = {} +): BrowserWindow { + const secureDefaults: BrowserWindowConstructorOptions = { + webPreferences: { + // Security: Isolate renderer from Node.js + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + + // Security: Disable dangerous features + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + enableWebSQL: false, + + // Security: Preload script for safe API exposure + preload: join(__dirname, "../preload/index.js"), + + // Security: Disable devtools in production + devTools: process.env.NODE_ENV === "development", + + // Performance: Disable unused features + spellcheck: false, + backgroundThrottling: true, + }, + + // Security: Prevent title from showing sensitive data + title: "My App", + }; + + return new BrowserWindow({ + ...secureDefaults, + ...options, + webPreferences: { + ...secureDefaults.webPreferences, + ...options.webPreferences, + }, + }); +} +``` + +--- + +## Crash Reporting and Telemetry + +### Crash Reporter Setup + +```typescript +// src/main/crash-reporter.ts +import { crashReporter, app } from "electron"; +import { join } from "path"; + +export function initializeCrashReporter(): void { + crashReporter.start({ + productName: app.getName(), + companyName: "Your Company", + submitURL: "https://your-crash-server.com/submit", + uploadToServer: true, + ignoreSystemCrashHandler: false, + rateLimit: true, + compress: true, + extra: { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + }, + }); + + // Log crash reports location for debugging + console.log("Crash reports path:", app.getPath("crashDumps")); +} + +export function addCrashContext(key: string, value: string): void { + crashReporter.addExtraParameter(key, value); +} +``` + +### Error Boundary in Main Process + +```typescript +// src/main/error-handler.ts +import { dialog, app } from "electron"; +import log from "electron-log"; + +export function setupErrorHandlers(): void { + // Unhandled promise rejections + process.on("unhandledRejection", (reason, promise) => { + log.error("Unhandled Rejection:", reason); + + if (process.env.NODE_ENV === "development") { + dialog.showErrorBox( + "Unhandled Promise Rejection", + String(reason) + ); + } + }); + + // Uncaught exceptions + process.on("uncaughtException", (error) => { + log.error("Uncaught Exception:", error); + + dialog.showErrorBox( + "Application Error", + `An unexpected error occurred: ${error.message}\n\nThe application will now quit.` + ); + + app.quit(); + }); + + // Renderer process crashes + app.on("render-process-gone", (event, webContents, details) => { + log.error("Renderer process gone:", details); + + if (details.reason === "crashed") { + const options = { + type: "error" as const, + title: "Window Crashed", + message: "This window has crashed.", + buttons: ["Reload", "Close"], + }; + + dialog.showMessageBox(options).then((result) => { + if (result.response === 0) { + webContents.reload(); + } + }); + } + }); + + // GPU process crashes + app.on("child-process-gone", (event, details) => { + log.error("Child process gone:", details); + + if (details.type === "GPU" && details.reason === "crashed") { + log.warn("GPU process crashed, app may have rendering issues"); + } + }); +} +``` + +--- + +## Native Module Integration + +### Rebuilding Native Modules + +```json +// package.json scripts +{ + "scripts": { + "postinstall": "electron-builder install-app-deps", + "rebuild": "electron-rebuild -f -w better-sqlite3,keytar" + } +} +``` + +### Native Module Usage Pattern + +```typescript +// src/main/services/database.ts +import Database from "better-sqlite3"; +import { app } from "electron"; +import { join } from "path"; + +export class DatabaseService { + private db: Database.Database; + + constructor() { + const dbPath = join(app.getPath("userData"), "app.db"); + this.db = new Database(dbPath, { + verbose: process.env.NODE_ENV === "development" ? console.log : undefined, + }); + + // Enable WAL mode for better concurrency + this.db.pragma("journal_mode = WAL"); + + // Initialize schema + this.initializeSchema(); + } + + private initializeSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content TEXT, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + `); + } + + getSetting(key: string): T | undefined { + const row = this.db + .prepare("SELECT value FROM settings WHERE key = ?") + .get(key) as { value: string } | undefined; + return row ? JSON.parse(row.value) : undefined; + } + + setSetting(key: string, value: T): void { + this.db + .prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, strftime('%s', 'now'))" + ) + .run(key, JSON.stringify(value)); + } + + close(): void { + this.db.close(); + } +} +``` + +--- + +## Troubleshooting + +### Common Issues + +Issue: "Electron could not be found" error +Symptoms: App fails to start, module not found +Solution: + +- Ensure electron is in devDependencies +- Run npm rebuild electron +- Check NODE_ENV is set correctly + +Issue: White screen on launch +Symptoms: Window opens but content doesn't load +Solution: + +- Check preload script path is correct +- Verify loadFile/loadURL path +- Enable devTools to see console errors +- Check for CSP blocking scripts + +Issue: IPC not working +Symptoms: invoke returns undefined, no response +Solution: + +- Verify channel names match exactly +- Check handler is registered before window loads +- Ensure contextBridge is used correctly +- Verify preload script is loaded + +Issue: Native modules fail to load +Symptoms: "Module was compiled against different Node.js version" +Solution: + +- Run electron-rebuild after npm install +- Match Electron Node.js version +- Use postinstall script for automatic rebuild + +Issue: Auto-update not working +Symptoms: No update notification, silent failure +Solution: + +- Check app is signed (required for updates) +- Verify publish configuration +- Check network/firewall settings +- Enable electron-updater logging + +--- + +## External Resources + +### Official Documentation + +- Electron Documentation: https://www.electronjs.org/docs +- Electron Forge: https://www.electronforge.io/ +- Electron Builder: https://www.electron.build/ + +### Security + +- Security Checklist: https://www.electronjs.org/docs/tutorial/security +- Context Isolation: https://www.electronjs.org/docs/tutorial/context-isolation + +### Build & Distribution + +- Code Signing: https://www.electronjs.org/docs/tutorial/code-signing +- Auto Updates: https://www.electronjs.org/docs/tutorial/updates +- macOS Notarization: https://www.electronjs.org/docs/tutorial/mac-app-store-submission-guide + +### Testing + +- Playwright: https://playwright.dev/ +- Testing Guide: https://www.electronjs.org/docs/tutorial/testing-on-headless-ci + +--- + +Version: 1.1.0 +Last Updated: 2026-01-10 +Changes: Added Utility Process patterns, Protocol Handlers, Deep Linking, OWASP-aligned Security Hardening, Crash Reporting, Native Module Integration diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8ba4f48 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(tree:*)"] + } +} From e509c01b9241015fd8a9482ec01c0e10fe639583 Mon Sep 17 00:00:00 2001 From: wenps <68772284+wenps@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:27:05 +0800 Subject: [PATCH 03/51] =?UTF-8?q?feat:=20=E5=9B=BD=E9=99=85=E5=8C=96?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lang/index.json | 1020 +++++++++++++++++++++++------------------------ 1 file changed, 510 insertions(+), 510 deletions(-) diff --git a/lang/index.json b/lang/index.json index ebf1d9b..d9af07d 100644 --- a/lang/index.json +++ b/lang/index.json @@ -1729,818 +1729,818 @@ }, "cvjdluk": { "zh-cn": "ir-ProzillaOS-选用-_0]", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ir-ProzillaOS-選択-_0]", + "ko": "ir-ProzillaOS-선택-_0]", + "ru": "ir-ProzillaOS-выбор-_0]", + "en": "ir-ProzillaOS-selection-_0]", + "fr": "ir-ProzillaOS-sélection-_0]" }, "nbek3z7": { "zh-cn": "编辑器字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "エディタのフォントサイズ", + "ko": "에디터 글꼴 크기", + "ru": "размер шрифта редактора", + "en": "Editor font size", + "fr": "Taille de police de l'éditeur" }, "b372gwa": { "zh-cn": "文本编辑器的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "テキストエディタのフォントサイズ", + "ko": "텍스트 에디터의 글꼴 크기", + "ru": "размер шрифта текстового редактора", + "en": "Font size of text editor", + "fr": "Taille de police de l'éditeur de texte" }, "rihv6q6": { "zh-cn": "代码字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "コードのフォントサイズ", + "ko": "코드 글꼴 크기", + "ru": "размер шрифта кода", + "en": "Code font size", + "fr": "Taille de police du code" }, "qwmffy9": { "zh-cn": "代码显示的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "コード表示のフォントサイズ", + "ko": "코드 표시 글꼴 크기", + "ru": "размер шрифта отображения кода", + "en": "Font size of code display", + "fr": "Taille de police de l'affichage du code" }, "z8ochc9": { "zh-cn": "一级标题的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レベル1見出しのフォントサイズ", + "ko": "1레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка первого уровня", + "en": "Font size of level 1 heading", + "fr": "Taille de police du titre de niveau 1" }, "tao6g49": { "zh-cn": "二级标题的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レベル2見出しのフォントサイズ", + "ko": "2레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка второго уровня", + "en": "Font size of level 2 heading", + "fr": "Taille de police du titre de niveau 2" }, "k58bon9": { "zh-cn": "三级标题的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レベル3見出しのフォントサイズ", + "ko": "3레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка третьего уровня", + "en": "Font size of level 3 heading", + "fr": "Taille de police du titre de niveau 3" }, "y4mch79": { "zh-cn": "四级标题的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レベル4見出しのフォントサイズ", + "ko": "4레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка четвертого уровня", + "en": "Font size of level 4 heading", + "fr": "Taille de police du titre de niveau 4" }, "fovv509": { "zh-cn": "五级标题的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レベル5見出しのフォントサイズ", + "ko": "5레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка пятого уровня", + "en": "Font size of level 5 heading", + "fr": "Taille de police du titre de niveau 5" }, "ux0q4d9": { "zh-cn": "六级标题的字体大小", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レベル6見出しのフォントサイズ", + "ko": "6레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка шестого уровня", + "en": "Font size of level 6 heading", + "fr": "Taille de police du titre de niveau 6" }, "bv1d1o4": { "zh-cn": "字体设置", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "フォント設定", + "ko": "글꼴 설정", + "ru": "настройка шрифта", + "en": "Font settings", + "fr": "Paramètres de police" }, "9obzlmf": { "zh-cn": " 配置编辑器和代码的字体样式 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " エディターとコードのフォントスタイルを設定 ", + "ko": " 에디터와 코드의 글꼴 스타일 구성 ", + "ru": " Настройка стиля шрифта редактора и кода ", + "en": " Configure the font style of editor and code ", + "fr": " Configurer le style de police de l'éditeur et du code " }, "rqjnhg6": { "zh-cn": " 字体选择 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " フォントの選択 ", + "ko": " 글꼴 선택 ", + "ru": " Выбор шрифта ", + "en": " Font selection ", + "fr": " Sélection de police " }, "p1j42": { "zh-cn": "选择", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "選択", + "ko": "선택", + "ru": "выбор", + "en": "Selection", + "fr": "Sélection" }, "bvptyo4": { "zh-cn": "字号设置", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "フォントサイズ設定", + "ko": "글꼴 크기 설정", + "ru": "настройка размера шрифта", + "en": "Font size settings", + "fr": "Paramètres de taille de police" }, "yzdvief": { "zh-cn": " 配置不同文本元素的字体大小 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 異なるテキスト要素のフォントサイズを設定 ", + "ko": " 다양한 텍스트 요소의 글꼴 크기 구성 ", + "ru": " Настройка размера шрифта различных текстовых элементов ", + "en": " Configure the font size of different text elements ", + "fr": " Configurer la taille de police des différents éléments de texte " }, "lzsxbc6": { "zh-cn": " 正文内容 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 本文コンテンツ ", + "ko": " 본문 내용 ", + "ru": " Основной текст ", + "en": " Main content ", + "fr": " Contenu principal " }, "ilv7mg6": { "zh-cn": " 一级标题 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " レベル1見出し ", + "ko": " 1레벨 제목 ", + "ru": " Заголовок первого уровня ", + "en": " Level 1 heading ", + "fr": " Titre de niveau 1 " }, "ggw0jw6": { "zh-cn": " 二级标题 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " レベル2見出し ", + "ko": " 2레벨 제목 ", + "ru": " Заголовок второго уровня ", + "en": " Level 2 heading ", + "fr": " Titre de niveau 2 " }, "igx2a76": { "zh-cn": " 三级标题 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " レベル3見出し ", + "ko": " 3레벨 제목 ", + "ru": " Заголовок третьего уровня ", + "en": " Level 3 heading ", + "fr": " Titre de niveau 3 " }, "g0mdj76": { "zh-cn": " 四级标题 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " レベル4見出し ", + "ko": " 4레벨 제목 ", + "ru": " Заголовок четвертого уровня ", + "en": " Level 4 heading ", + "fr": " Titre de niveau 4 " }, "gchnt06": { "zh-cn": " 五级标题 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " レベル5見出し ", + "ko": " 5레벨 제목 ", + "ru": " Заголовок пятого уровня ", + "en": " Level 5 heading ", + "fr": " Titre de niveau 5 " }, "57nn8r6": { "zh-cn": " 六级标题 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " レベル6見出し ", + "ko": " 6레벨 제목 ", + "ru": " Заголовок шестого уровня ", + "en": " Level 6 heading ", + "fr": " Titre de niveau 6 " }, "rih3a46": { "zh-cn": " 字体大小 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " フォントサイズ ", + "ko": " 글꼴 크기 ", + "ru": " размер шрифта ", + "en": " Font size ", + "fr": " Taille de police " }, "w5t755": { "zh-cn": " [只读]", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "[読み取り専用]", + "ko": "[읽기 전용]", + "ru": "[только для чтения]", + "en": "[Read-only]", + "fr": "[Lecture seule]" }, "rp05676": { "zh-cn": " [只读] ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " [読み取り専用] ", + "ko": " [읽기 전용] ", + "ru": " [только для чтения] ", + "en": " [Read-only] ", + "fr": " [Lecture seule] " }, "cjk8jz5": { "zh-cn": "[只读] ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "[読み取り専用] ", + "ko": "[읽기 전용] ", + "ru": "[только для чтения] ", + "en": "[Read-only] ", + "fr": "[Lecture seule] " }, "z3sj0e6": { "zh-cn": "保存文件失败", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイルの保存に失敗しました", + "ko": "파일 저장에 실패했습니다", + "ru": "Не удалось сохранить файл", + "en": "Failed to save file", + "fr": "Échec de la sauvegarde du fichier" }, "9rm15ze": { "zh-cn": "保存文件失败,请检查写入权限", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイルの保存に失敗しました。書き込み権限を確認してください", + "ko": "파일 저장에 실패했습니다. 쓰기 권한을 확인하세요", + "ru": "Не удалось сохранить файл. Проверьте права на запись", + "en": "Failed to save file, please check write permissions", + "fr": "Échec de la sauvegarde du fichier, veuillez vérifier les autorisations d'écriture" }, "ccpga9z": { "zh-cn": "文件已被外部修改,但您有未保存的更改。请先保存或丢弃更改后再重新加载。", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイルは外部から変更されましたが、保存されていない変更があります。変更を保存または破棄してから再読み込みしてください。", + "ko": "파일이 외부에서 수정되었지만 저장되지 않은 변경 사항이 있습니다. 변경 사항을 저장하거나 폐기한 후 다시 로드하세요.", + "ru": "Файл был изменен извне, но у вас есть несохраненные изменения. Сохраните или отбросьте изменения перед повторной загрузкой.", + "en": "The file has been modified externally, but you have unsaved changes. Please save or discard changes before reloading.", + "fr": "Le fichier a été modifié externement, mais vous avez des modifications non enregistrées. Veuillez enregistrer ou abandonner les modifications avant de recharger." }, "d4k9sh4": { "zh-cn": "文件 \"", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイル「", + "ko": "파일 \"", + "ru": "Файл «", + "en": "File \"", + "fr": "Fichier «" }, "t04it76": { "zh-cn": "\" 已被删除", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "」は削除されました", + "ko": "\"이 삭제되었습니다", + "ru": "» был удален", + "en": "\" has been deleted", + "fr": "» a été supprimé" }, "uje4506": { "zh-cn": "监听文件 \"", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイル「の監視中", + "ko": "파일 \"모니터링 중", + "ru": "При мониторинге файла «", + "en": "Error monitoring file \"", + "fr": "Erreur lors de la surveillance du fichier «" }, "egxfh7": { "zh-cn": "\" 时出错: ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "エラーが発生しました: ", + "ko": "오류 발생: ", + "ru": "возникла ошибка: ", + "en": "\": ", + "fr": "\": " }, "diprg14": { "zh-cn": "未知错误", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "不明なエラー", + "ko": "알 수 없는 오류", + "ru": "Неизвестная ошибка", + "en": "Unknown error", + "fr": "Erreur inconnue" }, "6lbcrh7": { "zh-cn": "文件已重新加载", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイルは再読み込みされました", + "ko": "파일이 다시 로드되었습니다", + "ru": "Файл был перезагружен", + "en": "File has been reloaded", + "fr": "Le fichier a été rechargé" }, "r2ntmg7": { "zh-cn": " 文件已更改 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " ファイルが変更されました ", + "ko": " 파일이 변경되었습니다 ", + "ru": " Файл был изменен ", + "en": " File has been changed ", + "fr": " Le fichier a été modifié " }, "i8xfh8c": { "zh-cn": "\" 已被其他程序修改。 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "」は他のプログラムによって変更されました。 ", + "ko": "\"이 다른 프로그램에 의해 수정되었습니다. ", + "ru": "» был изменен другой программой. ", + "en": "\" has been modified by another program. ", + "fr": "\" a été modifié par un autre programme. " }, "cbnuzvd": { "zh-cn": " 文件已被其他程序修改。 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " ファイルは他のプログラムによって変更されました。 ", + "ko": " 파일이 다른 프로그램에 의해 수정되었습니다. ", + "ru": " Файл был изменен другой программой. ", + "en": " File has been modified by another program. ", + "fr": " Le fichier a été modifié par un autre programme. " }, "ygx5m7r": { "zh-cn": " 是否要重新加载文件内容?当前未保存的更改将会丢失。 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " ファイルの内容を再読み込みしますか?現在保存されていない変更は失われます。 ", + "ko": " 파일 내용을 다시 로드하시겠습니까? 현재 저장되지 않은 변경 사항이 손실됩니다. ", + "ru": " Перезагрузить содержимое файла? Несохраненные изменения будут потеряны. ", + "en": " Do you want to reload the file content? Unsaved changes will be lost. ", + "fr": " Voulez-vous recharger le contenu du fichier? Les modifications non enregistrées seront perdues. " }, "u5udsw6": { "zh-cn": " 重新加载 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 再読み込み ", + "ko": " 다시 로드 ", + "ru": " Перезагрузить ", + "en": " Reload ", + "fr": " Recharger " }, "qzrwgd7": { "zh-cn": " 文件已变动 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " ファイルが変更されました ", + "ko": " 파일이 변경되었습니다 ", + "ru": " Файл был изменен ", + "en": " File has changed ", + "fr": " Le fichier a changé " }, "umfap0k": { "zh-cn": "\" 已经变动,是否覆盖当前编辑的内容? ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "」は変更されました。現在編集中の内容を上書きしますか? ", + "ko": "\"이 변경되었습니다. 현재 편집 중인 내용을 덮어쓰시겠습니까? ", + "ru": "» был изменен. Перезаписать текущее редактируемое содержимое? ", + "en": "\" has changed, overwrite the current edited content? ", + "fr": "\" a changé, écraser le contenu actuellement édité? " }, "fdal1pl": { "zh-cn": " 文件已经变动,是否覆盖当前编辑的内容? ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " ファイルが変更されました。現在編集中の内容を上書きしますか? ", + "ko": " 파일이 변경되었습니다. 현재 편집 중인 내용을 덮어쓰시겠습니까? ", + "ru": " Файл был изменен. Перезаписать текущее редактируемое содержимое? ", + "en": " The file has changed, overwrite the current edited content? ", + "fr": " Le fichier a changé, écraser le contenu actuellement édité? " }, "aroz724": { "zh-cn": "切换语言", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "言語を切り替える", + "ko": "언어 전환", + "ru": "Сменить язык", + "en": "Switch language", + "fr": "Changer de langue" }, "pmyml5i": { "zh-cn": "切换语言需要重启应用,是否现在重启?", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "言語を切り替えるにはアプリケーションの再起動が必要です。今すぐ再起動しますか?", + "ko": "언어 전환을 위해 앱을 다시 시작해야 합니다. 지금 다시 시작하시겠습니까?", + "ru": "Для смены языка требуется перезапуск приложения. Перезапустить сейчас?", + "en": "Switching language requires restarting the app, restart now?", + "fr": "Le changement de langue nécessite le redémarrage de l'application, redémarrer maintenant?" }, "p9fm2": { "zh-cn": "重启", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "再起動", + "ko": "재시작", + "ru": "Перезапуск", + "en": "Restart", + "fr": "Redémarrer" }, "ev022": { "zh-cn": "取消", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "キャンセル", + "ko": "취소", + "ru": "Отмена", + "en": "Cancel", + "fr": "Annuler" }, "g93bpeb": { "zh-cn": "请确保所有工作已经保存", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "すべての作業が保存されていることを確認してください", + "ko": "모든 작업이 저장되었는지 확인하세요", + "ru": "Убедитесь, что вся работа сохранена", + "en": "Please ensure all work has been saved", + "fr": "Veuillez vous assurer que tout le travail a été enregistré" }, "rq3kv48": { "zh-cn": " 稍后手动重启 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 後で手動で再起動 ", + "ko": " 나중에 수동으로 재시작 ", + "ru": " Перезапустить вручную позже ", + "en": " Restart manually later ", + "fr": " Redémarrer manuellement plus tard " }, "i4rsca6": { "zh-cn": " 现在重启 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 今すぐ再起動 ", + "ko": " 지금 재시작 ", + "ru": " Перезапустить сейчас ", + "en": " Restart now ", + "fr": " Redémarrer maintenant " }, "830mn1d": { "zh-cn": "请确保所有工作已经保存! ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "すべての作業が保存されていることを確認してください! ", + "ko": "모든 작업이 저장되었는지 확인하세요! ", + "ru": "Убедитесь, что вся работа сохранена! ", + "en": "Please ensure all work has been saved! ", + "fr": "Veuillez vous assurer que tout le travail a été enregistré! " }, "6m2p0dc": { "zh-cn": "请确保所有工作已经保存!", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "すべての作業が保存されていることを確認してください!", + "ko": "모든 작업이 저장되었는지 확인하세요!", + "ru": "Убедитесь, что вся работа сохранена!", + "en": "Please ensure all work has been saved!", + "fr": "Veuillez vous assurer que tout le travail a été enregistré!" }, "5mi5h2e": { "zh-cn": " 更新语言设置需要重启应用 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 言語設定を更新するにはアプリケーションの再起動が必要です ", + "ko": " 언어 설정 업데이트를 위해 앱 재시작이 필요합니다 ", + "ru": " Для обновления языковых настроек требуется перезапуск приложения ", + "en": " Updating language settings requires restarting the app ", + "fr": " La mise à jour des paramètres de langue nécessite le redémarrage de l'application " }, "onktj1e": { "zh-cn": " 请确保所有工作已经保存! ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " すべての作業が保存されていることを確認してください! ", + "ko": " 모든 작업이 저장되었는지 확인하세요! ", + "ru": " Убедитесь, что вся работа сохранена! ", + "en": " Please ensure all work has been saved! ", + "fr": " Veuillez vous assurer que tout le travail a été enregistré! " }, "kywilbf": { "zh-cn": "更新语言设置需要重启应用后生效", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "言語設定の更新はアプリケーションを再起動した後に有効になります", + "ko": "언어 설정 업데이트는 앱을 재시작한 후에 적용됩니다", + "ru": "Обновление языковых настроек вступит в силу после перезапуска приложения", + "en": "Updating language settings will take effect after restarting the app", + "fr": "La mise à jour des paramètres de langue prendra effet après le redémarrage de l'application" }, "eae82": { "zh-cn": "其他", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "その他", + "ko": "기타", + "ru": "Другое", + "en": "Other", + "fr": "Autre" }, "5d6g4k6": { "zh-cn": "编辑器内边距", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "エディタのパディング", + "ko": "에디터 패딩", + "ru": "Отступы редактора", + "en": "Editor padding", + "fr": "Remplissage de l'éditeur" }, "7xnwd2f": { "zh-cn": " 配置编辑器内容区域的内边距 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " エディタのコンテンツ領域のパディングを設定 ", + "ko": " 에디터 콘텐츠 영역의 패딩 구성 ", + "ru": " Настройка отступов области содержимого редактора ", + "en": " Configure padding for the editor content area ", + "fr": " Configurer le remplissage de la zone de contenu de l'éditeur " }, "j5j5y58": { "zh-cn": " 预览内容区域 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " プレビューコンテンツ領域 ", + "ko": " 미리보기 콘텐츠 영역 ", + "ru": " Область предварительного просмотра содержимого ", + "en": " Preview content area ", + "fr": " Zone de contenu de prévisualisation " }, "7puh5l7": { "zh-cn": " 内边距设置 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " パディング設定 ", + "ko": " 패딩 설정 ", + "ru": " Настройка отступов ", + "en": " Padding settings ", + "fr": " Paramètres de remplissage " }, "xm43kg11": { "zh-cn": " 设置编辑器内容区域的内边距,支持 CSS 单位(如 px、rem、%) ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " エディタのコンテンツ領域のパディングを設定します。CSS単位(px、rem、%など)に対応しています ", + "ko": " 에디터 콘텐츠 영역의 패딩을 설정합니다. CSS 단위(px, rem, % 등)를 지원합니다 ", + "ru": " Настройка отступов области содержимого редактора, поддерживаются единицы CSS (например, px, rem, %) ", + "en": " Set padding for the editor content area, supports CSS units (e.g., px, rem, %) ", + "fr": " Définir le remplissage de la zone de contenu de l'éditeur, prend en charge les unités CSS (par exemple, px, rem, %) " }, "cn2q13": { "zh-cn": "内边距", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "パディング", + "ko": "패딩", + "ru": "Отступы", + "en": "Padding", + "fr": "Remplissage" }, "dfykg38": { "zh-cn": "例如: 20px", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "例: 20px", + "ko": "예: 20px", + "ru": "Например: 20px", + "en": "Example: 20px", + "fr": "Exemple: 20px" }, "rm0wns6": { "zh-cn": " 当前值: ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 現在の値: ", + "ko": " 현재 값: ", + "ru": " Текущее значение: ", + "en": " Current value: ", + "fr": " Valeur actuelle: " }, "ok35ur7": { "zh-cn": "编辑器其他设置", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "エディタのその他の設定", + "ko": "에디터 기타 설정", + "ru": "Другие настройки редактора", + "en": "Other editor settings", + "fr": "Autres paramètres de l'éditeur" }, "qiczjwb": { "zh-cn": " 配置编辑器其他设置 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " エディタのその他の設定を構成 ", + "ko": " 에디터 기타 설정 구성 ", + "ru": " Настроить другие параметры редактора ", + "en": " Configure other editor settings ", + "fr": " Configurer d'autres paramètres de l'éditeur " }, "btd1wc4": { "zh-cn": "外观设置", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "外観設定", + "ko": "외관 설정", + "ru": "Настройки внешнего вида", + "en": "Appearance settings", + "fr": "Paramètres d'apparence" }, "oauiif9": { "zh-cn": "编辑器其他外观设置", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "エディタのその他の外観設定", + "ko": "에디터 기타 외관 설정", + "ru": "Другие настройки внешнего вида редактора", + "en": "Other appearance settings of editor", + "fr": "Autres paramètres d'apparence de l'éditeur" }, "u5f2sod": { "zh-cn": " 配置编辑器其他外观设置 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " エディタのその他の外観設定を構成 ", + "ko": " 에디터 기타 외관 설정 구성 ", + "ru": " Настроить другие параметры внешнего вида редактора ", + "en": " Configure other appearance settings of editor ", + "fr": " Configurer d'autres paramètres d'apparence de l'éditeur " }, "2j8e0g5": { "zh-cn": "请输入数字", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "数字を入力してください", + "ko": "숫자를 입력하세요", + "ru": "Введите число", + "en": "Please enter a number", + "fr": "Veuillez entrer un nombre" }, "rt9pmq7": { "zh-cn": "内边距(PX)", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "パディング(PX)", + "ko": "패딩(PX)", + "ru": "Отступы (PX)", + "en": "Padding (PX)", + "fr": "Remplissage (PX)" }, "3o81my8": { "zh-cn": "左右边距(PX)", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "左右のパディング(PX)", + "ko": "좌우 패딩(PX)", + "ru": "Лево-правые отступы (PX)", + "en": "Left and right padding (PX)", + "fr": "Remplissage gauche et droit (PX)" }, "7hktj26": { "zh-cn": "渲染中...", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "レンダリング中...", + "ko": "렌더링 중...", + "ru": "Отрисовка...", + "en": "Rendering...", + "fr": "Rendu en cours..." }, "glbv83": { "zh-cn": "流程图", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "フローチャート", + "ko": "플로우차트", + "ru": "Блок-схема", + "en": "Flow chart", + "fr": "Diagramme de flux" }, "fjmy2": { "zh-cn": "图表", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "チャート", + "ko": "차트", + "ru": "Диаграмма", + "en": "Chart", + "fr": "Graphique" }, "fe75x3": { "zh-cn": "时序图", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "シーケンスダイアグラム", + "ko": "시퀀스 다이어그램", + "ru": "Последовательная диаграмма", + "en": "Sequence diagram", + "fr": "Diagramme de séquence" }, "aftmxy4": { "zh-cn": "下载失败", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロードに失敗しました", + "ko": "다운로드에 실패했습니다", + "ru": "Не удалось скачать", + "en": "Download failed", + "fr": "Échec du téléchargement" }, "utnnr56": { "zh-cn": "更新出错: ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "更新中にエラーが発生しました: ", + "ko": "업데이트 중 오류 발생: ", + "ru": "Ошибка обновления: ", + "en": "Update error: ", + "fr": "Erreur de mise à jour: " }, "aftsuu4": { "zh-cn": "下载完成", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロードが完了しました", + "ko": "다운로드가 완료되었습니다", + "ru": "Загрузка завершена", + "en": "Download completed", + "fr": "Téléchargement terminé" }, "97iatz8": { "zh-cn": "正在下载... ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロード中... ", + "ko": "다운로드 중... ", + "ru": "Загрузка... ", + "en": "Downloading... ", + "fr": "Téléchargement en cours... " }, "90d1z06": { "zh-cn": " 立即更新 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 今すぐ更新 ", + "ko": " 즉시 업데이트 ", + "ru": " Обновить сейчас ", + "en": " Update now ", + "fr": " Mettre à jour maintenant " }, "v1i5cj8": { "zh-cn": " 下载中... ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " ダウンロード中... ", + "ko": " 다운로드 중... ", + "ru": " Загрузка... ", + "en": " Downloading... ", + "fr": " Téléchargement en cours... " }, "rzmztu6": { "zh-cn": " 重启安装 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " 再起動してインストール ", + "ko": " 재시작하여 설치 ", + "ru": " Перезапустить для установки ", + "en": " Restart to install ", + "fr": " Redémarrer pour installer " }, "d3b5r78": { "zh-cn": "点击恢复下载弹窗", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロード復元ポップアップをクリック", + "ko": "다운로드 복원 팝업 클릭", + "ru": "Нажмите на всплывающее окно восстановления загрузки", + "en": "Click to restore download popup", + "fr": "Cliquez pour restaurer la fenêtre contextuelle de téléchargement" }, "50uczt5": { "zh-cn": "正在下载 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロード中 ", + "ko": "다운로드 중 ", + "ru": "Загрузка ", + "en": "Downloading ", + "fr": "Téléchargement en cours " }, "5f4uk49": { "zh-cn": "下载完成,点击安装", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロードが完了しました。クリックしてインストールしてください", + "ko": "다운로드가 완료되었습니다. 클릭하여 설치하세요", + "ru": "Загрузка завершена, нажмите для установки", + "en": "Download completed, click to install", + "fr": "Téléchargement terminé, cliquez pour installer" }, "vz4lz7f": { "zh-cn": "milkup 新版本现已发布!", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "milkup の新しいバージョンがリリースされました!", + "ko": "milkup 새로운 버전이 출시되었습니다!", + "ru": "Новая версия milkup выпущена!", + "en": "A new version of milkup is now available!", + "fr": "Une nouvelle version de milkup est maintenant disponible!" }, "aik7f46": { "zh-cn": "前往发布页 ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": " リリースページに移動 ", + "ko": " 릴리즈 페이지로 이동 ", + "ru": " Перейти на страницу выпуска ", + "en": " Go to release page ", + "fr": " Aller à la page de publication " }, "2l6izt7": { "zh-cn": "正在下载...", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダウンロード中...", + "ko": "다운로드 중...", + "ru": "Загрузка...", + "en": "Downloading...", + "fr": "Téléchargement en cours..." }, "fj8br3": { "zh-cn": "最小化", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "最小化", + "ko": "최소화", + "ru": "Свернуть", + "en": "Minimize", + "fr": "Minimiser" }, "l0k0ei8": { "zh-cn": "当前已为最新版本", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "現在最新バージョンです", + "ko": "현재 최신 버전입니다", + "ru": "В настоящее время установлена последняя версия", + "en": "Currently the latest version", + "fr": "Version actuelle est la plus récente" }, "f7u97p8": { "zh-cn": "检查更新失败: ", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "更新の確認に失敗しました: ", + "ko": "업데이트 확인에 실패했습니다: ", + "ru": "Не удалось проверить обновления: ", + "en": "Failed to check for updates: ", + "fr": "Échec de la vérification des mises à jour: " }, "lxz2q6h": { "zh-cn": "milkup 是完全免费开源的软件", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "milkup は完全に無料のオープンソースソフトウェアです", + "ko": "milkup은 완전 무료 오픈소스 소프트웨어입니다", + "ru": "milkup — это полностью бесплатное программное обеспечение с открытым исходным кодом", + "en": "milkup is completely free and open-source software", + "fr": "milkup est un logiciel entièrement gratuit et open source" }, "qy6ip4b": { "zh-cn": "配置编辑器其他外观设置", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "エディタのその他の外観設定を構成", + "ko": "에디터 기타 외관 설정 구성", + "ru": "Настроить другие параметры внешнего вида редактора", + "en": "Configure other appearance settings of editor", + "fr": "Configurer d'autres paramètres d'apparence de l'éditeur" }, "wzudk98": { "zh-cn": "双击修改 URL", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ダブルクリックでURLを編集", + "ko": "더블 클릭하여 URL 수정", + "ru": "Дважды щелкните, чтобы изменить URL", + "en": "Double-click to edit URL", + "fr": "Double-cliquez pour modifier l'URL" }, "v66gef9": { "zh-cn": "修改链接 URL:", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "リンクURLを編集:", + "ko": "링크 URL 수정:", + "ru": "Изменить URL ссылки:", + "en": "Edit link URL:", + "fr": "Modifier l'URL du lien :" }, "a6fdb94": { "zh-cn": "上传文件", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイルをアップロード", + "ko": "파일 업로드", + "ru": "Загрузить файл", + "en": "Upload file", + "fr": "Télécharger un fichier" }, "l9122": { "zh-cn": "确认", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "確認", + "ko": "확인", + "ru": "Подтвердить", + "en": "Confirm", + "fr": "Confirmer" }, "ieee177": { "zh-cn": "粘贴链接...", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "リンクを貼り付け...", + "ko": "링크 붙여넣기...", + "ru": "Вставить ссылку...", + "en": "Paste link...", + "fr": "Coller le lien..." }, "ikzkgf4": { "zh-cn": "选择文件", - "ja": "", - "ko": "", - "ru": "", - "en": "", - "fr": "" + "ja": "ファイルを選択", + "ko": "파일 선택", + "ru": "Выбрать файл", + "en": "Select file", + "fr": "Sélectionner un fichier" } } From 8c5fc69b3bddebbae64d3dea3c53e982035a6b7c Mon Sep 17 00:00:00 2001 From: Larry Zhu Date: Tue, 3 Feb 2026 18:08:21 +0800 Subject: [PATCH 04/51] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20AI=20?= =?UTF-8?q?=E7=BB=AD=E5=86=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lang/index.json | 280 +++++++++++++++ .../components/editor/MilkdownEditor.vue | 2 + .../editor/plugins/completionPlugin.ts | 177 ++++++++++ .../components/settings/AISetting.vue | 321 ++++++++++++++++++ .../components/settings/ImageConfig.vue | 48 +-- .../components/settings/SettingBase.vue | 11 +- .../components/settings/SpellCheckSetter.vue | 22 +- .../components/settings/UploadConfig.vue | 51 +-- .../components/ui/selector/Selector.vue | 89 +++-- src/renderer/components/ui/slider/Slider.vue | 169 +++++++++ src/renderer/components/ui/slider/index.ts | 1 + src/renderer/components/ui/switch/Switch.vue | 81 +++++ src/renderer/components/ui/switch/index.ts | 1 + src/renderer/hooks/useAIConfig.ts | 57 ++++ src/renderer/hooks/useSpellCheck.ts | 52 +-- src/renderer/services/ai.ts | 276 +++++++++++++++ 16 files changed, 1526 insertions(+), 112 deletions(-) create mode 100644 src/renderer/components/editor/plugins/completionPlugin.ts create mode 100644 src/renderer/components/settings/AISetting.vue create mode 100644 src/renderer/components/ui/slider/Slider.vue create mode 100644 src/renderer/components/ui/slider/index.ts create mode 100644 src/renderer/components/ui/switch/Switch.vue create mode 100644 src/renderer/components/ui/switch/index.ts create mode 100644 src/renderer/hooks/useAIConfig.ts create mode 100644 src/renderer/services/ai.ts diff --git a/lang/index.json b/lang/index.json index d9af07d..bddeafa 100644 --- a/lang/index.json +++ b/lang/index.json @@ -2542,5 +2542,285 @@ "ru": "Выбрать файл", "en": "Select file", "fr": "Sélectionner un fichier" + }, + "bsy8dm5": { + "zh-cn": "未命名文档", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "i7ej2": { + "zh-cn": "未知", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "m16cho4w": { + "zh-cn": "你是一个技术文档续写助手。\n严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:\n\n{\"continuation\": \"接下来只写3–35个汉字的自然衔接内容\"}\n\n另外,如果 API 支持 structured outputs / json_schema / response_format 则在 API 层级限制。", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "tq3vltb": { + "zh-cn": "2-20 个汉字的续写", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ogrq8qa": { + "zh-cn": "上下文:\n文章标题:", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "buavkc5": { + "zh-cn": "\n大标题:", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ud32b87": { + "zh-cn": "\n本小节标题:", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "z351ktd": { + "zh-cn": "\n前面内容(请紧密衔接):", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "b11tpw7": { + "zh-cn": "AI 续写设置", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "78ytez5": { + "zh-cn": "连接成功!", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ikbexi4": { + "zh-cn": "连接成功", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "q9hql0a": { + "zh-cn": "连接失败,请检查配置", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ika8634": { + "zh-cn": "连接失败", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "9u4wy46": { + "zh-cn": "连接出错: ", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "jdhqxo4": { + "zh-cn": "错误: ", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "n63u0z8": { + "zh-cn": "启用 AI 续写", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "jkvovdg": { + "zh-cn": "服务提供商 (Provider)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "vwo89ea": { + "zh-cn": "模型 (Model)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "mennovb": { + "zh-cn": "正在加载模型列表...", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "a1m9965": { + "zh-cn": "未找到模型", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "61g4yc6": { + "zh-cn": "刷新模型列表", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "o9xxdrj": { + "zh-cn": "随机性 (Temperature): ", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "nxfz0r6": { + "zh-cn": "测试中...", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "edfmk14": { + "zh-cn": "测试连接", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "fy1g2": { + "zh-cn": "失败", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "q4mu2": { + "zh-cn": "错误", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "u4t3ke8": { + "zh-cn": "获取模型列表失败", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "cf4xcp5": { + "zh-cn": "服务提供商", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "f91vmhh": { + "zh-cn": "随机性 (Temperature)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "9ofd4sf": { + "zh-cn": "触发延迟 (Debounce)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "mer6ks6": { + "zh-cn": "快 (1s)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "n8m1it7": { + "zh-cn": "适中 (2s)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "d2x0tb6": { + "zh-cn": "慢 (3s)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "fx6eua2r": { + "zh-cn": "你是一个技术文档续写助手。\n严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:\n\n{\"continuation\": \"接下来只写3–35个汉字的自然衔接内容\"}\n", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "a0oapib": { + "zh-cn": "3–35 个汉字的续写", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" } } diff --git a/src/renderer/components/editor/MilkdownEditor.vue b/src/renderer/components/editor/MilkdownEditor.vue index 4cfcd62..df6f5ad 100644 --- a/src/renderer/components/editor/MilkdownEditor.vue +++ b/src/renderer/components/editor/MilkdownEditor.vue @@ -16,6 +16,7 @@ import { processImagePaths, reverseProcessImagePaths } from "@/plugins/imagePath import { laxImageInputRule, laxImagePastePlugin } from "@/plugins/laxImagePlugin"; import { sourceOnFocusPlugin } from "@renderer/enhance/crepe/plugins/sourceOnFocus"; import { diagram } from "@/plugins/mermaidPlugin"; +import { completionPlugin } from "./plugins/completionPlugin"; import emitter from "@/renderer/events"; import useTab from "@/renderer/hooks/useTab"; @@ -166,6 +167,7 @@ onMounted(async () => { .use(upload) .use(htmlPlugin) .use(diagram) + .use(completionPlugin) .use(sourceOnFocusPlugin) .use(commonmark); diff --git a/src/renderer/components/editor/plugins/completionPlugin.ts b/src/renderer/components/editor/plugins/completionPlugin.ts new file mode 100644 index 0000000..f9b1b40 --- /dev/null +++ b/src/renderer/components/editor/plugins/completionPlugin.ts @@ -0,0 +1,177 @@ +import { Plugin, PluginKey } from "@milkdown/kit/prose/state"; +import { Decoration, DecorationSet, EditorView } from "@milkdown/kit/prose/view"; +import { $prose } from "@milkdown/kit/utils"; +import { AIService } from "@/renderer/services/ai"; +import { useAIConfig } from "@/renderer/hooks/useAIConfig"; + +export const completionKey = new PluginKey("milkup-completion"); + +export const completionPlugin = $prose(() => { + const { config, isEnabled } = useAIConfig(); + let timer: NodeJS.Timeout | null = null; + + return new Plugin({ + key: completionKey, + state: { + init() { + return { decoration: DecorationSet.empty, suggestion: null, loading: false }; + }, + apply(tr, value) { + // Clear suggestion on any document change + if (tr.docChanged) { + return { decoration: DecorationSet.empty, suggestion: null, loading: false }; + } + // Update decoration if manually dispatched (e.g. by async fetch) + const meta = tr.getMeta(completionKey); + if (meta) { + return meta; + } + return value; + }, + }, + props: { + decorations(state) { + return this.getState(state)?.decoration; + }, + handleKeyDown(view, event) { + if (event.key === "Tab") { + const state = this.getState(view.state); + if (state?.suggestion) { + event.preventDefault(); + const tr = view.state.tr.insertText(state.suggestion, view.state.selection.to); + // Clear the suggestion + tr.setMeta(completionKey, { + decoration: DecorationSet.empty, + suggestion: null, + loading: false, + }); + view.dispatch(tr); + return true; + } + } + return false; + }, + }, + view(_view) { + return { + update: (view: EditorView, prevState) => { + // If disabled, do nothing + if (!isEnabled.value) return; + + // If doc didn't change, ignore (unless it was our own transaction clearing things) + if (!view.state.doc.eq(prevState.doc)) { + if (timer) clearTimeout(timer); + + // Simple logic: Trigger only if user stopped typing at the end of a block?? + // Or just anywhere? Let's obey the debounce rule globally for now. + + timer = setTimeout(async () => { + // Ensure editor is still focused? Maybe not strictly required but good UX + // if (!view.hasFocus()) return + + const { selection, doc } = view.state; + const { to } = selection; + + // Don't trigger if selection is not empty (range selection) + if (!selection.empty) return; + + // Retrieve context + // Build simple context: previous 100 chars + // We can improve this by walking up the tree to find headers + const fileTitle = (window as any).__currentFilePath + ? (window as any).__currentFilePath.split(/[\\/]/).pop() + : "未命名文档"; + + // Naively get previous text + const start = Math.max(0, to - 200); + const previousContent = doc.textBetween(start, to, "\n"); + + // Extract headers context for better AI awareness + let sectionTitle = "未知"; + let subSectionTitle = "未知"; + + const headers: { level: number; text: string }[] = []; + + doc.nodesBetween(0, to, (node, pos) => { + if (node.type.name === "heading") { + // Only take headers that are strictly before the cursor position + if (pos + node.nodeSize <= to) { + headers.push({ level: node.attrs.level, text: node.textContent }); + } + return false; // Don't descend into heading (optimization) + } + // Skip content of other blocks to improve performance + if ( + ["paragraph", "code_block", "blockquote", "bullet_list", "ordered_list"].includes( + node.type.name + ) + ) { + return false; + } + return true; + }); + + if (headers.length > 0) { + const lastHeader = headers[headers.length - 1]; + subSectionTitle = lastHeader.text; + + // Find the "Big Title" (Section Title) + // Looks for the nearest ancestor header (lower level number) + const parentHeader = headers + .slice(0, -1) + .reverse() + .find((h) => h.level < lastHeader.level); + + if (parentHeader) { + sectionTitle = parentHeader.text; + } else { + // If no direct parent found (e.g. H2...H2), find the document title (H1) or just fallback + const mainHeader = + headers.find((h) => h.level === 1) || headers.find((h) => h.level === 2); + if (mainHeader && mainHeader !== lastHeader) { + sectionTitle = mainHeader.text; + } else if (lastHeader.level <= 2) { + sectionTitle = lastHeader.text; + } + } + } + + if (previousContent.trim().length < 5) return; // Too short context + + try { + const result = await AIService.complete(config.value, { + fileTitle, + previousContent, + sectionTitle, + subSectionTitle, + }); + + if (result && result.continuation) { + const widget = document.createElement("span"); + widget.textContent = result.continuation; + widget.style.color = "var(--text-color-light, #999)"; // Use a lighter color + widget.style.opacity = "0.6"; + widget.style.pointerEvents = "none"; + widget.dataset.suggestion = result.continuation; + + // Create deco + const deco = Decoration.widget(to, widget, { side: 1 }); + const decoSet = DecorationSet.create(view.state.doc, [deco]); + + // Dispatch state update + const tr = view.state.tr.setMeta(completionKey, { + decoration: decoSet, + suggestion: result.continuation, + }); + view.dispatch(tr); + } + } catch (e) { + console.error("AI Completion failed", e); + } + }, config.value.debounceWait || 2000); + } + }, + }; + }, + }); +}); diff --git a/src/renderer/components/settings/AISetting.vue b/src/renderer/components/settings/AISetting.vue new file mode 100644 index 0000000..63e31d3 --- /dev/null +++ b/src/renderer/components/settings/AISetting.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/src/renderer/components/settings/ImageConfig.vue b/src/renderer/components/settings/ImageConfig.vue index d72f41e..c3fa650 100644 --- a/src/renderer/components/settings/ImageConfig.vue +++ b/src/renderer/components/settings/ImageConfig.vue @@ -1,18 +1,20 @@ - @@ -23,9 +25,12 @@ function handleChangeLoaclPath() {
- -
-
- 图片将自动转为 base64(可能会增大文件体积) +
+
图片将自动转为 base64(可能会增大文件体积)
- diff --git a/src/renderer/components/settings/UploadConfig.vue b/src/renderer/components/settings/UploadConfig.vue index 304ce85..8e0a5a2 100644 --- a/src/renderer/components/settings/UploadConfig.vue +++ b/src/renderer/components/settings/UploadConfig.vue @@ -1,33 +1,41 @@ - - diff --git a/src/renderer/components/ui/slider/index.ts b/src/renderer/components/ui/slider/index.ts new file mode 100644 index 0000000..765baab --- /dev/null +++ b/src/renderer/components/ui/slider/index.ts @@ -0,0 +1 @@ +export { default as Slider } from "./Slider.vue"; diff --git a/src/renderer/components/ui/switch/Switch.vue b/src/renderer/components/ui/switch/Switch.vue new file mode 100644 index 0000000..5b0674b --- /dev/null +++ b/src/renderer/components/ui/switch/Switch.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/renderer/components/ui/switch/index.ts b/src/renderer/components/ui/switch/index.ts new file mode 100644 index 0000000..c986f8a --- /dev/null +++ b/src/renderer/components/ui/switch/index.ts @@ -0,0 +1 @@ +export { default as Switch } from "./Switch.vue"; diff --git a/src/renderer/hooks/useAIConfig.ts b/src/renderer/hooks/useAIConfig.ts new file mode 100644 index 0000000..b84825a --- /dev/null +++ b/src/renderer/hooks/useAIConfig.ts @@ -0,0 +1,57 @@ +import { useStorage } from "@vueuse/core"; +import { computed } from "vue"; + +export type AIProvider = "openai" | "anthropic" | "gemini" | "ollama" | "custom"; + +export interface AIConfig { + enabled: boolean; + provider: AIProvider; + baseUrl: string; + apiKey: string; + model: string; + temperature: number; + debounceWait: number; +} + +const defaultAIConfig: AIConfig = { + enabled: false, + provider: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: "", + model: "gpt-3.5-turbo", + temperature: 0.7, + debounceWait: 2000, +}; + +// Default URLs for providers +export const providerDefaultUrls: Record = { + openai: "https://api.openai.com/v1", + anthropic: "https://api.anthropic.com", + gemini: "https://generativelanguage.googleapis.com", + ollama: "http://localhost:11434", + custom: "", +}; + +const config = useStorage("milkup-ai-config", defaultAIConfig, localStorage, { + mergeDefaults: true, +}); + +export function useAIConfig() { + const isEnabled = computed(() => config.value.enabled); + + const updateConfig = (updates: Partial) => { + config.value = { ...config.value, ...updates }; + }; + + const resetToDefault = () => { + config.value = { ...defaultAIConfig }; + }; + + return { + config, + isEnabled, + updateConfig, + resetToDefault, + providerDefaultUrls, + }; +} diff --git a/src/renderer/hooks/useSpellCheck.ts b/src/renderer/hooks/useSpellCheck.ts index 3db736a..d6616c3 100644 --- a/src/renderer/hooks/useSpellCheck.ts +++ b/src/renderer/hooks/useSpellCheck.ts @@ -1,30 +1,38 @@ -import { nextTick, onMounted, ref } from 'vue' - -function applySpellCheck(isEnabled: boolean) { - nextTick(() => { - const html = document.documentElement - if (isEnabled) { - html.setAttribute('spellcheck', 'true') - localStorage.setItem('spellcheck', 'true') - } else { - html.setAttribute('spellcheck', 'false') - localStorage.setItem('spellcheck', 'false') - } - }) -} +import { nextTick, onMounted, ref } from "vue"; export default function useSpellCheck() { - const isSpellCheckEnabled = ref(localStorage.getItem('spellcheck') === 'true') - applySpellCheck(isSpellCheckEnabled.value) + const isSpellCheckEnabled = ref(localStorage.getItem("spellcheck") === "true"); + + function applySpellCheck(isEnabled: boolean) { + isSpellCheckEnabled.value = isEnabled; + nextTick(() => { + const html = document.documentElement; + if (isEnabled) { + html.setAttribute("spellcheck", "true"); + localStorage.setItem("spellcheck", "true"); + } else { + html.setAttribute("spellcheck", "false"); + localStorage.setItem("spellcheck", "false"); + } + }); + } + + // Initialize on mount/creation + applySpellCheck(isSpellCheckEnabled.value); + onMounted(() => { - const savedSpellCheck = localStorage.getItem('spellcheck') - if (savedSpellCheck) { - isSpellCheckEnabled.value = savedSpellCheck === 'true' - applySpellCheck(isSpellCheckEnabled.value) + const savedSpellCheck = localStorage.getItem("spellcheck"); + if (savedSpellCheck !== null) { + // Logic above already sets initial state, but explicit check ensures sync + const isEnabled = savedSpellCheck === "true"; + if (isEnabled !== isSpellCheckEnabled.value) { + applySpellCheck(isEnabled); + } } - }) + }); + return { isSpellCheckEnabled, applySpellCheck, - } + }; } diff --git a/src/renderer/services/ai.ts b/src/renderer/services/ai.ts new file mode 100644 index 0000000..0f1987f --- /dev/null +++ b/src/renderer/services/ai.ts @@ -0,0 +1,276 @@ +import type { AIConfig } from "@/renderer/hooks/useAIConfig"; + +export interface APIContext { + fileTitle?: string; + sectionTitle?: string; + subSectionTitle?: string; + previousContent: string; +} + +export interface CompletionResponse { + continuation: string; +} + +const SYSTEM_PROMPT = `你是一个技术文档续写助手。 +严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**: + +{"continuation": "接下来只写3–35个汉字的自然衔接内容"} +`; + +const RESPONSE_SCHEMA = { + type: "json_schema", + json_schema: { + name: "short_continuation", + strict: true, + schema: { + type: "object", + properties: { + continuation: { + type: "string", + description: "3–35 个汉字的续写", + minLength: 2, + maxLength: 35, + }, + }, + required: ["continuation"], + additionalProperties: false, + }, + }, +}; + +export class AIService { + private static async request(url: string, options: RequestInit): Promise { + try { + const response = await fetch(url, options); + if (!response.ok) { + let errorMsg = `HTTP Error: ${response.status}`; + try { + const errorBody = await response.json(); + errorMsg += ` - ${JSON.stringify(errorBody)}`; + } catch { + errorMsg += ` - ${await response.text()}`; + } + throw new Error(errorMsg); + } + return await response.json(); + } catch (error: any) { + console.error("[AI Service] Request failed:", error); + throw error; + } + } + + private static buildPrompt(context: APIContext): string { + return `上下文: +文章标题:${context.fileTitle || "未知"} +大标题:${context.sectionTitle || "未知"} +本小节标题:${context.subSectionTitle || "未知"} +前面内容(请紧密衔接):${context.previousContent}`; + } + + private static parseResponse(text: string): CompletionResponse { + try { + // 1. Try generic JSON parsing + // Remove generic markdown code block if present + const cleanText = text.replace(/```json\n?|\n?```/g, "").trim(); + const json = JSON.parse(cleanText); + if (json.continuation) { + return { continuation: json.continuation }; + } + } catch (e) { + console.warn("[AI Service] JSON parse failed, trying regex extraction", e); + } + + // 2. Regex fallback + const match = text.match(/"continuation"\s*:\s*"([^"]+)"/); + if (match && match[1]) { + return { continuation: match[1] }; + } + + // 3. Last resort fallback (if AI just returned text) + if (text.length < 50 && !text.includes("{")) { + return { continuation: text.trim() }; + } + + throw new Error("Failed to parse AI response"); + } + + static async testConnection(config: AIConfig): Promise { + if (!config.baseUrl) throw new Error("Base URL is required"); + + switch (config.provider) { + case "ollama": + try { + // Check tags for Ollama + await this.request(`${config.baseUrl}/api/tags`, { method: "GET" }); + return true; + } catch { + return false; + } + default: + // For others, we might try a minimal model list call or just assume verified if user saves? + // Let's try to list models for OpenAI compatible APIs + if (config.provider === "openai" || config.provider === "custom") { + try { + await this.request(`${config.baseUrl}/models`, { + headers: { Authorization: `Bearer ${config.apiKey}` }, + }); + return true; + } catch { + // Some endpoints might not support /models, maybe try a tiny completion? + // But usually /models is standard. + return false; + } + } + return true; // Fallback for Anthropic/Gemini verification implementation later if needed + } + } + + static async getModels(config: AIConfig): Promise { + if (config.provider === "ollama") { + try { + const res = await this.request(`${config.baseUrl}/api/tags`, { method: "GET" }); + return res.models?.map((m: any) => m.name) || []; + } catch (e) { + console.error("Failed to fetch Ollama models", e); + return []; + } + } + return []; + } + + static async complete(config: AIConfig, context: APIContext): Promise { + const userMessage = this.buildPrompt(context); + + let url = ""; + let headers: Record = { + "Content-Type": "application/json", + }; + let body: any = {}; + + switch (config.provider) { + case "openai": + case "custom": + url = `${config.baseUrl}/chat/completions`; + headers["Authorization"] = `Bearer ${config.apiKey}`; + body = { + model: config.model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ], + temperature: config.temperature, + stream: false, + // OpenAI Structured Outputs + response_format: RESPONSE_SCHEMA, + }; + break; + + case "anthropic": + url = `${config.baseUrl}/v1/messages`; + headers["x-api-key"] = config.apiKey; + headers["anthropic-version"] = "2023-06-01"; + // Anthropic doesn't have "response_format" in the same way, but we can stick to prompt engineering + // OR use tool use if we wanted strict enforcement, but for simple completion, prompt is often enough. + // However, user asked to use API level restrictions if available. + // Anthropic specific: prefill assistant message to force JSON, or use tools. + // Let's use the tool constraint approach which is the "Standard" way for structured output in Claude now. + + const toolSchema = { + name: "print_continuation", + description: "Print the continuation text", + input_schema: { + type: "object", + properties: { + continuation: { + type: "string", + description: "3–35 个汉字的续写", + }, + }, + required: ["continuation"], + }, + }; + + body = { + model: config.model, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: userMessage }], + max_tokens: 1024, + stream: false, + tools: [toolSchema], + tool_choice: { type: "tool", name: "print_continuation" }, + }; + break; + + case "gemini": + // Gemini API Structured Output + url = `${config.baseUrl}/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`; + body = { + contents: [ + { + parts: [{ text: SYSTEM_PROMPT + "\n" + userMessage }], + }, + ], + generationConfig: { + responseMimeType: "application/json", + responseSchema: { + type: "OBJECT", + properties: { + continuation: { type: "STRING" }, + }, + required: ["continuation"], + }, + maxOutputTokens: 1024, + }, + }; + break; + + case "ollama": + url = `${config.baseUrl}/api/chat`; + body = { + model: config.model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ], + // Ollama supports JSON Schema object in 'format' since v0.5.x + format: RESPONSE_SCHEMA.json_schema.schema, + stream: false, + options: { + temperature: config.temperature, + }, + }; + break; + } + + const response = await this.request(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + let content = ""; + + if (config.provider === "openai" || config.provider === "custom") { + content = response.choices?.[0]?.message?.content || ""; + } else if (config.provider === "anthropic") { + // Handle tool use response + if (response.content) { + const toolUse = response.content.find((c: any) => c.type === "tool_use"); + if (toolUse && toolUse.input) { + // Directly return parsed input as it is already JSON object + if (toolUse.input.continuation) { + return { continuation: toolUse.input.continuation }; + } + } + // Fallback to text if tool wasn't used properly (unlikely with tool_choice forced) + content = response.content.find((c: any) => c.type === "text")?.text || ""; + } + } else if (config.provider === "gemini") { + content = response.candidates?.[0]?.content?.parts?.[0]?.text || ""; + } else if (config.provider === "ollama") { + content = response.message?.content || ""; + } + + return this.parseResponse(content); + } +} From 8d3a49a98886b53b8d869f26da7eb1f765d93be6 Mon Sep 17 00:00:00 2001 From: Larry Zhu Date: Fri, 6 Feb 2026 10:40:15 +0800 Subject: [PATCH 05/51] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E9=80=89=E9=A1=B9(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 2 +- lang/index.json | 16 ++++ src/main/index.ts | 4 + .../dialogs/ReloadConfirmDialog.vue | 31 +++---- src/renderer/components/settings/Language.vue | 87 ++++++++++++------- .../components/ui/selector/Selector.vue | 49 +++++++---- src/renderer/hooks/useTab.ts | 26 +++--- vite.config.mts | 3 + 8 files changed, 148 insertions(+), 70 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8ba4f48..330f1bc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,5 +1,5 @@ { "permissions": { - "allow": ["Bash(tree:*)"] + "allow": ["Bash(tree:*)", "Bash(git log:*)", "Bash(ls:*)", "Bash(dir:*)"] } } diff --git a/lang/index.json b/lang/index.json index bddeafa..72d83c7 100644 --- a/lang/index.json +++ b/lang/index.json @@ -2822,5 +2822,21 @@ "ru": "", "en": "", "fr": "" + }, + "cvzdn9p": { + "zh-cn": "图片将自动转为 base64(可能会增大文件体积)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "68tpr19": { + "zh-cn": " 全部保存并重启 ", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" } } diff --git a/src/main/index.ts b/src/main/index.ts index 3c43f13..bf6e7a7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -54,6 +54,10 @@ async function createWindow() { // 防止应用内部跳转(直接点击链接) win.webContents.on("will-navigate", (event, url) => { + // 允许 dev server 的 reload + if (process.env.VITE_DEV_SERVER_URL && url.startsWith(process.env.VITE_DEV_SERVER_URL)) { + return; + } if (url.startsWith("https:") || url.startsWith("http:")) { event.preventDefault(); shell.openExternal(url); diff --git a/src/renderer/components/dialogs/ReloadConfirmDialog.vue b/src/renderer/components/dialogs/ReloadConfirmDialog.vue index 7043042..fa820df 100644 --- a/src/renderer/components/dialogs/ReloadConfirmDialog.vue +++ b/src/renderer/components/dialogs/ReloadConfirmDialog.vue @@ -1,12 +1,18 @@ @@ -14,21 +20,16 @@ function handleRealod() {
-

- 请确保所有工作已经保存! -

+

请确保所有工作已经保存!

更新语言设置需要重启应用后生效
diff --git a/src/renderer/components/settings/Language.vue b/src/renderer/components/settings/Language.vue index cb7824a..0e1dcf8 100644 --- a/src/renderer/components/settings/Language.vue +++ b/src/renderer/components/settings/Language.vue @@ -1,44 +1,74 @@ @@ -58,9 +88,8 @@ async function selectLanguage() { color: var(--text-color); } - select { - padding: 5px; - font-size: 16px; + .selector-wrapper { + width: 300px; } p { diff --git a/src/renderer/components/ui/selector/Selector.vue b/src/renderer/components/ui/selector/Selector.vue index d570a75..760c3df 100644 --- a/src/renderer/components/ui/selector/Selector.vue +++ b/src/renderer/components/ui/selector/Selector.vue @@ -1,5 +1,5 @@