3 Commits

Author SHA1 Message Date
8e2bafba5f Optimize window snapping performance 2025-09-04 00:20:30 +08:00
6149bc133d 🐛 Fixed window pinning issue 2025-09-02 23:59:04 +08:00
5f22ee3b1f ♻️ Refactoring configuration migration service 2025-08-31 17:48:41 +08:00
38 changed files with 1028 additions and 1022 deletions

View File

@@ -1,142 +0,0 @@
---
description: THXAN-2-CN
globs:
alwaysApply: false
---
---
name: AI Chief Architect (AI首席架构师)
version: 7.3.0
author: thxan & Gemini
---
# 核心身份 (Core Identity)
- 你是一个顶尖的AI首席架构师你的核心交互界面是IDE。
- 你的首要任务是通过严谨的、文档驱动的工作流来管理和开发软件项目。
- 你的每一次操作和决策都必须是可追溯、可审计的。
# 指导原则 (Guiding Principles)
- **首要原则:文档驱动 (Documentation-Driven)。** 整个项目以`.docs/`目录作为其单一事实来源(Single Source of Truth, SSoT)。对话是临时的,文档是永恒的。你必须通过读写文档来持久化上下文、状态和知识。
- **第二原则:提案先行 (Proposal-First)。** 对于任何非原子性的复杂任务(如功能开发、重构),必须先生成一份结构化的提案并通过用户审批,才能进入实施阶段。
- **第三原则:持续自省 (Continuous Introspection)。** 在完成任务后,你必须回顾流程,提炼可复用的模式,并将其固化为新的规则,以实现持续进化。
# 核心机制与工具 (Core Mechanics & Tools)
- **工具集授权:** 你被授权使用以下工具来与文件系统交互:
- `read_file(path)`: 读取指定文件的内容。
- `write_file(path, content)`: 将内容写入指定文件,会覆盖原有内容。
- `list_files(path)`: 列出指定目录下的文件和文件夹。
- **状态报告协议:** 在你的每次响应结束时,**必须**附加一行来说明你的当前状态,格式为:`状态: [工作流代码][当前模块代码][子状态]`。例如: `状态: [W2][M4][AwaitingApproval]`.
- **SSoT 结构:**
- **背景上下文位置:** `.docs/`
- **启动加载 (MUST LOAD):**
- **BOOTLOADER:** `.docs/README-DOCS.md`
- **MEMORY:** `.docs/STATE/MEMORY.md`
- **RULES:** `.docs/RULES/*` (所有规则文件)
- **按需加载 (Optional):** `.docs/*` 下的其他相关文档。
---
# 核心工作流 (Core Workflow)
这是你的决策核心。你必须首先分析用户请求,并结合项目的当前状态,从以下工作流中选择一个且仅一个来执行,并**在回应的开头明确声明你选择了哪个工作流**。
### **决策触发器 (Decision Triggers):**
1. **IF** `BOOTLOADER` 文件不存在
* **THEN** 激活 **[W1_ProjectInitialization]**
2. **ELSE IF** 用户意图涉及一个需要规划、设计和分步执行的复杂开发或修改任务 (关键词如: "实现", "开发新功能", "重构", "修改需求")
* **THEN** 激活 **[W2_ProposalDrivenDevelopment]**
3. **ELSE IF** 用户意图是一个可以直接执行的原子性任务 (关键词如: "运行测试", "画架构图", "审计文档", "检查同步")
* **THEN** 激活 **[W3_DirectCommandExecution]**
4. **ELSE** (对于所有其他情况,如请求模糊不清或前置条件不足)
* **THEN** 激活 **[W4_ContextClarification]**
### **工作流定义 (Workflow Definitions):**
- **[W1_ProjectInitialization]**
- **执行模块:** `M1_Project_Onboarding`
- **[W2_ProposalDrivenDevelopment]**
- **执行模块:** `M2_Context_Loading` -> `M4_Analyze_And_Propose` -> `M5_Implement_From_Proposal` -> `M6_Sync_With_Truth` -> `M7_Review_And_Generalize`
- **[W3_DirectCommandExecution]**
- **执行模块:** `M2_Context_Loading` -> (根据用户具体指令选择 `M8_Test_And_Validate`, `M9_Visualize_Architecture`, 或 `M10_Audit_Documentation` 中的一个)
- **[W4_ContextClarification]**
- **执行模块:** `M2_Context_Loading` -> `M3_Clarification_And_Retry` (执行后**必须返回决策触发器**重新开始分析流程)
---
# 核心模块库 (Core Modules Library)
这是你的能力单元,由上述工作流进行调用。
- **`[M1_Project_Onboarding]`**: 初始化新项目建立SSoT的基础架构。
- [1a] 识别为新项目,进入初始化流程。
- [1b] 主动提问以明确项目目标、技术栈。
- [1c] 创建`.docs/`目录结构及核心模板。
- [1d] 创建`README-DOCS.md`并建立核心“状态基线”文档初稿。
- [1e] 调用 `[M11_State_Management]` 初始化并保存初始状态。
- **`[M2_Context_Loading]`**: 在执行任何任务前,加载必要的上下文。
- [2a] 调用 `[M11]` 加载当前状态。
- [2b] **[必须加载]:** 根据`核心机制与工具`部分的定义,加载所有启动文件。
- [2c] **[智能加载]:** 从用户请求中提取关键词,查找并阅读最相关的附加上下文文件。
- [2d] 加载完毕后,在内存中形成上下文基准,并更新状态文件。
- **`[M3_Clarification_And_Retry]`**:
- [3a] 明确报告哪个任务无法执行,以及缺失了什么具体信息。
- [3b] 分析问题原因,并向用户提供一个或多个可执行的、用于解决问题的建议。
- **`[M4_Analyze_And_Propose]`**:
- [4a] **意图分析:** 复述核心需求以确认理解。
- [4b] **提案驱动协议:** 对于复杂任务,生成一份结构化提案,并等待用户批准。
```mermaid
stateDiagram-v2
direction LR
[*] --> AnalyzingIntent
AnalyzingIntent --> GeneratingProposal: on Complex task
GeneratingProposal --> AwaitingApproval
AwaitingApproval --> Implementing: Approved
AwaitingApproval --> GeneratingProposal: Rejected
Implementing --> SyncingSSoT
SyncingSSoT --> Learning
Learning --> [*]
```
- [4c] **提案生成:** 调用 `[M12_Generate_File_Creation_Command]` 和 `[M13_Generate_Structured_Proposal]` 创建提案文件并起草内容,然后报告状态为 `[AwaitingApproval]`。
- **`[M5_Implement_From_Proposal]`**:
- [5a] 读取用户指定的 `proposal_file`。
- [5b] **原子化执行:** 严格按文件中的清单,将“代码-文档-测试”视为不可分割的单元同步完成。
- [5c] **状态同步:** 每完成一步,更新提案文件(`[ ]` -> `[x]`)并调用 `[M11]` 保存进度。
- [5d] 完成后,自动触发 `[M6]` 和 `[M7]`。
- **`[M6_Sync_With_Truth]`**:
- [6a] 分析最近的代码变更集,交叉比对 `.docs/STATE/` 下的基线文档。
- [6b] 报告 `[✅ SSoT同步完成]` 或提出 `[⚠️ 关键文档更新]` 建议。
- **`[M7_Review_And_Generalize]`**:
- [7a] 分析任务全流程,若发现可复用的模式,主动起草新规则并提请用户批准存入 `.docs/RULES/`。
- **`[M8_Test_And_Validate]`**:
- [8a] 分析目标文件,若测试不存在则生成测试骨架。
- [8b] 若测试存在,则运行相关测试脚本并报告结果。
- **`[M9_Visualize_Architecture]`**:
- [9a] 扫描指定范围代码识别依赖关系并使用Mermaid.js语法生成图表。
- [9b] 主动询问是否需要将图表保存到 `.docs/ARCHITECTURE/`。
- **`[M10_Audit_Documentation]`**:
- [10a] 执行交叉引用分析,生成包含 `[🚫 Missing Docs]`、`[🔗 Broken Links]`、`[⚠️ Stale Docs]`的审计报告。
- **`[M11_State_Management]`**:
- [11a] 通过读写 `.docs/STATE/MEMORY.md` 来加载、保存或清理当前任务状态。
- **`[M12_Generate_File_Creation_Command]`**:
- [12a] **职责:** 生成一个`bash`命令来创建一个带有标准时间戳的新文件(提案、决策等)。
- [12b] **格式:** `touch {base_path}/${prefix}_$(date +%Y%m%d_%H%M%S)_${description}.md`
- [12c] **注意:** 命令执行后需捕获并使用该文件名进行后续读写。
- **`[M13_Generate_Structured_Proposal]`**:
- [13a] 在指定文件中生成结构化的提案草稿,包含背景、目标、设计方案、风险、测试计划等。
- **`[M14_Self_Correction]`**:
- [14a] 当发现自身行为违反`指导原则`时自动触发。
- [14b] **立即停止**当前不当任务,**明确承认错误**,并引用违反的原则。
- [14c] **重新返回核心工作流**,以正确的流程生成回答。
# **[其他]**
** 如果不能确认获取的时间是否正确,请使用命令行 date来获取当前时间

View File

@@ -1,10 +1,10 @@
# <img src="./frontend/public/appicon.png" alt="VoidRaft Logo" width="32" height="32" style="vertical-align: middle;"> VoidRaft
# <img src="./frontend/public/appicon.png" alt="voidraft Logo" width="32" height="32" style="vertical-align: middle;"> voidraft
[中文](README_ZH.md) | **English**
> *An elegant text snippet recording tool designed for developers.*
VoidRaft is a modern developer-focused text editor that allows you to record, organize, and manage various text snippets anytime, anywhere. Whether it's temporary code snippets, API responses, meeting notes, or daily to-do lists, VoidRaft provides a smooth and elegant editing experience.
voidraft is a modern developer-focused text editor that allows you to record, organize, and manage various text snippets anytime, anywhere. Whether it's temporary code snippets, API responses, meeting notes, or daily to-do lists, voidraft provides a smooth and elegant editing experience.
## Core Features
@@ -87,7 +87,7 @@ After building, the executable will be generated in the `bin` directory.
## Project Structure
```
Voidraft/
voidraft/
├── frontend/ # Vue 3 frontend application
│ ├── src/
│ │ ├── views/editor/ # Editor core views
@@ -129,11 +129,11 @@ Voidraft/
> Standing on the shoulders of giants, paying tribute to the open source spirit
The birth of VoidRaft is inseparable from the following excellent open source projects:
The birth of voidraft is inseparable from the following excellent open source projects:
### Special Thanks
- **[Heynote](https://github.com/heyman/heynote/)** - VoidRaft is a feature-enhanced version based on Heynote's concept
- **[Heynote](https://github.com/heyman/heynote/)** - voidraft is a feature-enhanced version based on Heynote's concept
- Inherits Heynote's elegant block editing philosophy
- Adds more practical features on the original foundation
- Rebuilt with modern technology stack
@@ -157,7 +157,7 @@ This project is open source under the [MIT License](LICENSE).
Welcome to Fork, Star, and contribute code.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/Voidraft.svg?style=social&label=Star)](https://github.com/yourusername/Voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/Voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/Voidraft)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/voidraft.svg?style=social&label=Star)](https://github.com/yourusername/voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/voidraft)
*Made with ❤️ by landaiqing*

View File

@@ -1,10 +1,10 @@
# <img src="./frontend/public/appicon.png" alt="Voidraft Logo" width="32" height="32" style="vertical-align: middle;"> Voidraft
# <img src="./frontend/public/appicon.png" alt="voidraft Logo" width="32" height="32" style="vertical-align: middle;"> voidraft
**中文** | [English](README.md)
> *一个专为开发者打造的优雅文本片段记录工具。*
Voidraft 是一个现代化的开发者专用文本编辑器让你能够随时随地记录、整理和管理各种文本片段。无论是临时的代码片段、API 响应、会议笔记,还是日常的待办事项,Voidraft 都能为你提供流畅而优雅的编辑体验。
voidraft 是一个现代化的开发者专用文本编辑器让你能够随时随地记录、整理和管理各种文本片段。无论是临时的代码片段、API 响应、会议笔记,还是日常的待办事项,voidraft 都能为你提供流畅而优雅的编辑体验。
## 核心特性
@@ -88,7 +88,7 @@ wails3 package
## 项目结构
```
Voidraft/
voidraft/
├── frontend/ # Vue 3 前端应用
│ ├── src/
│ │ ├── views/editor/ # 编辑器核心视图
@@ -131,11 +131,11 @@ Voidraft/
> 站在巨人的肩膀上,致敬开源精神
Voidraft 的诞生离不开以下优秀的开源项目:
voidraft 的诞生离不开以下优秀的开源项目:
### 特别感谢
- **[Heynote](https://github.com/heyman/heynote/)** - Voidraft 是基于 Heynote 概念的功能增强版本
- **[Heynote](https://github.com/heyman/heynote/)** - voidraft 是基于 Heynote 概念的功能增强版本
- 继承了 Heynote 优雅的块状编辑理念
- 在原有基础上增加了更多实用功能
- 采用现代化技术栈重新构建
@@ -159,7 +159,7 @@ Voidraft 的诞生离不开以下优秀的开源项目:
欢迎 Fork、Star 和贡献代码。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/Voidraft.svg?style=social&label=Star)](https://github.com/yourusername/Voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/Voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/Voidraft)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/voidraft.svg?style=social&label=Star)](https://github.com/yourusername/voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/voidraft)
*Made with ❤️ by landaiqing*

View File

@@ -12,13 +12,25 @@ vars:
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
version:
summary: Generate version information
cmds:
- '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}'
sources:
- scripts/version.bat
- scripts/version.sh
generates:
- version.txt
build:
summary: Builds the application
deps: [version]
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
deps: [version]
cmds:
- task: "{{OS}}:package"

View File

@@ -5,12 +5,12 @@ version: '3'
# This information is used to generate the build assets.
info:
companyName: "Voidraft" # The name of the company
productName: "Voidraft" # The name of the application
companyName: "voidraft" # The name of the company
productName: "voidraft" # The name of the application
productIdentifier: "landaiqing" # The unique product identifier
description: "Voidraft" # The application description
copyright: "© 2025 Voidraft. All rights reserved." # Copyright text
comments: "Voidraft" # Comments
description: "voidraft" # The application description
copyright: "© 2025 voidraft. All rights reserved." # Copyright text
comments: "voidraft" # Comments
version: "0.0.1.0" # The application version
# Dev mode configuration

View File

@@ -4,15 +4,15 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleExecutable</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleIdentifier</key>
<string>landaiqing</string>
<key>CFBundleVersion</key>
<string>0.0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1.0</string>
<key>CFBundleIconFile</key>
@@ -22,7 +22,7 @@
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2025 Voidraft. All rights reserved.</string>
<string>© 2025 voidraft. All rights reserved.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>

View File

@@ -4,15 +4,15 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleExecutable</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleIdentifier</key>
<string>landaiqing</string>
<key>CFBundleVersion</key>
<string>0.0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1.0</string>
<key>CFBundleIconFile</key>
@@ -22,6 +22,6 @@
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2025 Voidraft. All rights reserved.</string>
<string>© 2025 voidraft. All rights reserved.</string>
</dict>
</plist>

View File

@@ -11,9 +11,12 @@ tasks:
- task: common:build:frontend
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}'
VERSION_FLAGS:
sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:

View File

@@ -11,9 +11,12 @@ tasks:
- task: common:build:frontend
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}'
VERSION_FLAGS:
sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"'
env:
GOOS: linux
CGO_ENABLED: 1

View File

@@ -3,26 +3,26 @@
#
# The lines below are called `modelines`. See `:help modeline`
name: "Voidraft"
name: "voidraft"
arch: ${GOARCH}
platform: "linux"
version: "0.0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Voidraft"
vendor: "Voidraft"
homepage: "https://wails.io"
description: "voidraft"
vendor: "voidraft"
homepage: "https://voidraft.landaiqing.cn"
license: "MIT"
release: "1"
contents:
- src: "./bin/Voidraft"
dst: "/usr/local/bin/Voidraft"
- src: "./bin/voidraft"
dst: "/usr/local/bin/voidraft"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/Voidraft.png"
- src: "./build/linux/Voidraft.desktop"
dst: "/usr/share/applications/Voidraft.desktop"
dst: "/usr/share/icons/hicolor/128x128/apps/voidraft.png"
- src: "./build/linux/voidraft.desktop"
dst: "/usr/share/applications/voidraft.desktop"
depends:
- gtk3

View File

@@ -14,13 +14,16 @@ tasks:
- task: common:generate:icons
cmds:
- task: generate:syso
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s -H windowsgui{{else}}{{end}}'
VERSION_FLAGS:
sh: 'powershell -Command "(Get-Content version.txt) -replace ''VERSION='', ''-X voidraft/internal/version.Version=''"'
env:
GOOS: windows
CGO_ENABLED: 1

View File

@@ -5,11 +5,11 @@
"info": {
"0000": {
"ProductVersion": "0.0.1.0",
"CompanyName": "Voidraft",
"FileDescription": "Voidraft",
"LegalCopyright": "© 2025 Voidraft. All rights reserved.",
"ProductName": "Voidraft",
"Comments": "Voidraft"
"CompanyName": "voidraft",
"FileDescription": "voidraft",
"LegalCopyright": "© 2025 voidraft. All rights reserved.",
"ProductName": "voidraft",
"Comments": "voidraft"
}
}
}

View File

@@ -5,19 +5,19 @@
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "Voidraft"
!define INFO_PROJECTNAME "voidraft"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Voidraft"
!define INFO_COMPANYNAME "voidraft"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "Voidraft"
!define INFO_PRODUCTNAME "voidraft"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.1.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2025 Voidraft. All rights reserved."
!define INFO_COPYRIGHT "© 2025 voidraft. All rights reserved."
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoidRaft - Changelog</title>
<title>voidraft - Changelog</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/changelog.css">
<link rel="icon" href="img/favicon.ico">
@@ -16,7 +16,7 @@
<!-- 主卡片 -->
<div class="card">
<div class="card-header">
<h1 class="card-title" data-en="VoidRaft Changelog" data-zh="VoidRaft 更新日志">VoidRaft Changelog</h1>
<h1 class="card-title" data-en="voidraft Changelog" data-zh="voidraft 更新日志">voidraft Changelog</h1>
<div class="card-controls">
<button id="theme-toggle" class="btn btn-secondary" title="切换主题">
<i class="fas fa-sun"></i> <span data-en="Theme" data-zh="主题">Theme</span>
@@ -59,7 +59,7 @@
<!-- 页脚 -->
<footer class="footer">
<p class="footer-text" data-en="© 2025 VoidRaft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 VoidRaft - 专为开发者打造的优雅文本片段记录工具">© 2023-2024 VoidRaft - An elegant text snippet recording tool designed for developers</p>
<p class="footer-text" data-en="© 2025 voidraft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 voidraft - 专为开发者打造的优雅文本片段记录工具">© 2023-2024 voidraft - An elegant text snippet recording tool designed for developers</p>
<div class="footer-links">
<a href="https://github.com/landaiqing/voidraft" target="_blank" class="footer-link">GitHub</a>
<a href="https://github.com/landaiqing/voidraft/issues" target="_blank" class="footer-link" data-en="Issues" data-zh="问题反馈">Issues</a>

View File

@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoidRaft - An elegant text snippet recording tool designed for developers.</title>
<meta name="description" content="VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta name="keywords" content="text editor, code snippets, developer tools, syntax highlighting, code formatting, multi-language, VoidRaft">
<meta name="author" content="VoidRaft Team">
<title>voidraft - An elegant text snippet recording tool designed for developers.</title>
<meta name="description" content="voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta name="keywords" content="text editor, code snippets, developer tools, syntax highlighting, code formatting, multi-language, voidraft">
<meta name="author" content="voidraft Team">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://landaiqing.github.io/voidraft/">
@@ -18,16 +18,16 @@
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://landaiqing.github.io/voidraft/">
<meta property="og:title" content="VoidRaft - An elegant text snippet recording tool designed for developers">
<meta property="og:description" content="VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="og:title" content="voidraft - An elegant text snippet recording tool designed for developers">
<meta property="og:description" content="voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="og:image" content="https://landaiqing.github.io/voidraft/img/screenshot-dark.png">
<meta property="og:site_name" content="VoidRaft">
<meta property="og:site_name" content="voidraft">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://landaiqing.github.io/voidraft/">
<meta property="twitter:title" content="VoidRaft - An elegant text snippet recording tool designed for developers">
<meta property="twitter:description" content="VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="twitter:title" content="voidraft - An elegant text snippet recording tool designed for developers">
<meta property="twitter:description" content="voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="twitter:image" content="https://landaiqing.github.io/voidraft/img/screenshot-dark.png">
<link rel="stylesheet" href="./css/styles.css">
@@ -39,13 +39,13 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "VoidRaft",
"name": "voidraft",
"description": "An elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.",
"url": "https://landaiqing.github.io/voidraft/",
"downloadUrl": "https://github.com/landaiqing/voidraft/releases",
"author": {
"@type": "Organization",
"name": "VoidRaft"
"name": "voidraft"
},
"operatingSystem": ["Windows", "macOS", "Linux"],
"applicationCategory": "DeveloperApplication",
@@ -66,7 +66,7 @@
<!-- 主卡片 -->
<div class="card">
<div class="card-header">
<h1 class="card-title">VoidRaft</h1>
<h1 class="card-title">voidraft</h1>
<div class="card-controls">
<button id="theme-toggle" class="btn btn-secondary" title="切换主题">
<i class="fas fa-sun"></i> <span data-en="Theme" data-zh="主题">Theme</span>
@@ -81,9 +81,9 @@
<!-- Logo和介绍 -->
<div class="logo-container">
<div class="logo-frame">
<img src="img/logo.png" alt="VoidRaft Logo" class="logo-image">
<img src="img/logo.png" alt="voidraft Logo" class="logo-image">
</div>
<h2 class="logo-text" data-en="VoidRaft" data-zh="VoidRaft">VoidRaft</h2>
<h2 class="logo-text" data-en="voidraft" data-zh="voidraft">voidraft</h2>
<p class="tagline" data-en="An elegant text snippet recording tool" data-zh="优雅的文本片段记录工具">An elegant text snippet recording tool</p>
</div>
@@ -195,15 +195,15 @@
<div class="block-language">text</div>
</div>
<pre class="code-block">
<span class="comment">// VoidRaft - An elegant text snippet recording tool</span>
<span class="comment">// voidraft - An elegant text snippet recording tool</span>
<span class="comment">// Multi-language support | Code formatting | Custom themes</span>
<span class="comment">// A modern text editor designed for developers</span></pre>
</div>
</div>
</div>
<div class="preview-window">
<img src="img/screenshot-dark.png" alt="VoidRaft 界面预览" class="preview-image dark-theme-img">
<img src="img/screenshot-light.png" alt="VoidRaft 界面预览" class="preview-image light-theme-img" style="display: none;">
<img src="img/screenshot-dark.png" alt="voidraft 界面预览" class="preview-image dark-theme-img">
<img src="img/screenshot-light.png" alt="voidraft 界面预览" class="preview-image light-theme-img" style="display: none;">
</div>
</div>
@@ -241,7 +241,7 @@
<!-- 页脚 -->
<footer class="footer">
<p class="footer-text" data-en="© 2025 VoidRaft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 VoidRaft - 专为开发者打造的优雅文本片段记录工具">© 2025 VoidRaft - An elegant text snippet recording tool designed for developers</p>
<p class="footer-text" data-en="© 2025 voidraft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 voidraft - 专为开发者打造的优雅文本片段记录工具">© 2025 voidraft - An elegant text snippet recording tool designed for developers</p>
<div class="footer-links">
<a href="https://github.com/landaiqing/voidraft" target="_blank" class="footer-link">GitHub</a>
<a href="https://github.com/landaiqing/voidraft/issues" target="_blank" class="footer-link" data-en="Issues" data-zh="问题反馈">Issues</a>

View File

@@ -1,5 +1,5 @@
/**
* VoidRaft - Changelog Script
* voidraft - Changelog Script
* 从GitHub API获取发布信息支持Gitea备用源
*/

View File

@@ -1,5 +1,5 @@
/**
* VoidRaft - Website Script
* voidraft - Website Script
*/
/**
@@ -233,14 +233,14 @@ class SEOManager {
this.languageManager = languageManager;
this.metaTexts = {
en: {
description: 'VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.',
title: 'VoidRaft - An elegant text snippet recording tool designed for developers.',
ogTitle: 'VoidRaft - An elegant text snippet recording tool designed for developers'
description: 'voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.',
title: 'voidraft - An elegant text snippet recording tool designed for developers.',
ogTitle: 'voidraft - An elegant text snippet recording tool designed for developers'
},
zh: {
description: 'VoidRaft 是专为开发者打造的优雅文本片段记录工具。支持多语言代码块、语法高亮、代码格式化、自定义主题等功能。',
title: 'VoidRaft - 专为开发者打造的优雅文本片段记录工具',
ogTitle: 'VoidRaft - 专为开发者打造的优雅文本片段记录工具'
description: 'voidraft 是专为开发者打造的优雅文本片段记录工具。支持多语言代码块、语法高亮、代码格式化、自定义主题等功能。',
title: 'voidraft - 专为开发者打造的优雅文本片段记录工具',
ogTitle: 'voidraft - 专为开发者打造的优雅文本片段记录工具'
}
};
this.init();
@@ -371,9 +371,9 @@ class UIEffects {
}
/**
* VoidRaft主应用类
* voidraft主应用类
*/
class VoidRaftApp {
class voidraftApp {
constructor() {
this.themeManager = null;
this.languageManager = null;
@@ -404,7 +404,7 @@ class VoidRaftApp {
* 显示控制台品牌信息
*/
showConsoleBranding() {
console.log('%c VoidRaft', 'color: #ff006e; font-size: 20px; font-family: "Space Mono", monospace;');
console.log('%c voidraft', 'color: #ff006e; font-size: 20px; font-family: "Space Mono", monospace;');
console.log('%c An elegant text snippet recording tool designed for developers.', 'color: #073B4C; font-family: "Space Mono", monospace;');
}
@@ -439,5 +439,5 @@ class VoidRaftApp {
// 当DOM加载完成时初始化应用
document.addEventListener('DOMContentLoaded', () => {
window.voidRaftApp = new VoidRaftApp();
window.voidRaftApp = new voidraftApp();
});

View File

@@ -469,15 +469,10 @@ export class GeneralConfig {
/**
* 窗口吸附设置
* 是否启用窗口吸附功能
* 是否启用窗口吸附功能(阈值现在是自适应的)
*/
"enableWindowSnap": boolean;
/**
* 吸附距离阈值(像素)
*/
"snapThreshold": number;
/**
* 全局热键设置
* 是否启用全局热键
@@ -506,9 +501,6 @@ export class GeneralConfig {
if (!("enableWindowSnap" in $$source)) {
this["enableWindowSnap"] = false;
}
if (!("snapThreshold" in $$source)) {
this["snapThreshold"] = 0;
}
if (!("enableGlobalHotkey" in $$source)) {
this["enableGlobalHotkey"] = false;
}
@@ -523,10 +515,10 @@ export class GeneralConfig {
* Creates a new GeneralConfig instance from a string or object.
*/
static createFrom($$source: any = {}): GeneralConfig {
const $$createField7_0 = $$createType8;
const $$createField6_0 = $$createType8;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("globalHotkey" in $$parsedSource) {
$$parsedSource["globalHotkey"] = $$createField7_0($$parsedSource["globalHotkey"]);
$$parsedSource["globalHotkey"] = $$createField6_0($$parsedSource["globalHotkey"]);
}
return new GeneralConfig($$parsedSource as Partial<GeneralConfig>);
}

View File

@@ -34,6 +34,22 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
return $typingPromise;
}
/**
* GetConfigDir 获取配置目录
*/
export function GetConfigDir(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2275626561) as any;
return $resultPromise;
}
/**
* GetSettingsPath 获取设置文件路径
*/
export function GetSettingsPath(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2175583370) as any;
return $resultPromise;
}
/**
* ResetConfig 强制重置所有配置为默认值
*/

View File

@@ -22,11 +22,19 @@ export function Cleanup(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* GetCurrentThreshold 获取当前自适应阈值(用于调试或显示)
*/
export function GetCurrentThreshold(): Promise<number> & { cancel(): void } {
let $resultPromise = $Call.ByID(3176419026) as any;
return $resultPromise;
}
/**
* OnWindowSnapConfigChanged 处理窗口吸附配置变更
*/
export function OnWindowSnapConfigChanged(enabled: boolean, threshold: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3794787039, enabled, threshold) as any;
export function OnWindowSnapConfigChanged(enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3794787039, enabled) as any;
return $resultPromise;
}
@@ -62,22 +70,6 @@ export function SetSnapEnabled(enabled: boolean): Promise<void> & { cancel(): vo
return $resultPromise;
}
/**
* SetSnapThreshold 设置窗口吸附阈值
*/
export function SetSnapThreshold(threshold: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(586790049, threshold) as any;
return $resultPromise;
}
/**
* StartWindowSnapMonitor 启动窗口吸附监听器
*/
export function StartWindowSnapMonitor(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(84533508) as any;
return $resultPromise;
}
/**
* UnregisterWindow 取消注册窗口
*/

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import {useI18n} from 'vue-i18n';
import {onMounted, onUnmounted, ref, watch, computed} from 'vue';
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useEditorStore} from '@/stores/editorStore';
import {useUpdateStore} from '@/stores/updateStore';
import {useWindowStore} from '@/stores/windowStore';
import {useSystemStore} from '@/stores/systemStore';
import * as runtime from '@wailsio/runtime';
import {useRouter} from 'vue-router';
import BlockLanguageSelector from './BlockLanguageSelector.vue';
@@ -17,22 +18,32 @@ const editorStore = useEditorStore();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
const windowStore = useWindowStore();
const systemStore = useSystemStore();
const {t} = useI18n();
const router = useRouter();
// 当前块是否支持格式化的响应式状态
const canFormatCurrentBlock = ref(false);
// 窗口置顶状态管理(仅当前窗口,不同步到配置文件)
const isCurrentWindowOnTop = ref(false);
const setWindowAlwaysOnTop = async (isTop: boolean) => {
await runtime.Window.SetAlwaysOnTop(isTop);
};
// 窗口置顶状态 - 合并配置和临时状态
const isCurrentWindowOnTop = computed(() => {
return configStore.config.general.alwaysOnTop || systemStore.isWindowOnTop;
});
// 切换窗口置顶状态
const toggleAlwaysOnTop = async () => {
isCurrentWindowOnTop.value = !isCurrentWindowOnTop.value;
await runtime.Window.SetAlwaysOnTop(isCurrentWindowOnTop.value);
const currentlyOnTop = isCurrentWindowOnTop.value;
if (currentlyOnTop) {
// 如果当前是置顶状态,彻底关闭所有置顶
if (configStore.config.general.alwaysOnTop) {
await configStore.setAlwaysOnTop(false);
}
await systemStore.setWindowOnTop(false);
} else {
// 如果当前不是置顶状态,开启临时置顶
await systemStore.setWindowOnTop(true);
}
};
// 跳转到设置页面
@@ -80,7 +91,7 @@ const debouncedUpdateFormatButton = (() => {
timeout = window.setTimeout(() => {
updateFormatButtonState();
timeout = null;
}, 300);
}, 1000);
};
})();
@@ -143,12 +154,28 @@ onUnmounted(() => {
// 组件加载后初始化置顶状态
watch(isLoaded, async (loaded) => {
if (loaded) {
// 初始化时从配置文件读取置顶状态
isCurrentWindowOnTop.value = configStore.config.general.alwaysOnTop;
await setWindowAlwaysOnTop(isCurrentWindowOnTop.value);
// 应用合并后的置顶状态
const shouldBeOnTop = configStore.config.general.alwaysOnTop || systemStore.isWindowOnTop;
try {
await runtime.Window.SetAlwaysOnTop(shouldBeOnTop);
} catch (error) {
console.error('Failed to apply window pin state:', error);
}
}
});
// 监听配置变化,同步窗口状态
watch(
() => isCurrentWindowOnTop.value,
async (shouldBeOnTop) => {
try {
await runtime.Window.SetAlwaysOnTop(shouldBeOnTop);
} catch (error) {
console.error('Failed to sync window pin state:', error);
}
}
);
const handleUpdateButtonClick = async () => {
if (updateStore.hasUpdate && !updateStore.isUpdating && !updateStore.updateSuccess) {
// 开始下载更新
@@ -230,20 +257,23 @@ const updateButtonTitle = computed(() => {
@click="handleUpdateButtonClick"
>
<!-- 检查更新中 -->
<svg v-if="updateStore.isChecking" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
<svg v-if="updateStore.isChecking" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotating">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<!-- 下载更新中 -->
<svg v-else-if="updateStore.isUpdating" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
<svg v-else-if="updateStore.isUpdating" xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotating">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
<path d="M12 2a10 10 0 1 0 10 10"></path>
</svg>
<!-- 更新成功等待重启 -->
<svg v-else-if="updateStore.updateSuccess" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
<svg v-else-if="updateStore.updateSuccess" xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pulsing">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
@@ -252,7 +282,8 @@ const updateButtonTitle = computed(() => {
<!-- 有更新可用 -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="7.5,10.5 12,15 16.5,10.5"/>
<polyline points="12,15 12,3"/>
</svg>

View File

@@ -64,7 +64,6 @@ const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
enableGlobalHotkey: 'general.enableGlobalHotkey',
globalHotkey: 'general.globalHotkey',
enableWindowSnap: 'general.enableWindowSnap',
snapThreshold: 'general.snapThreshold'
} as const;
const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
@@ -180,7 +179,6 @@ const DEFAULT_CONFIG: AppConfig = {
key: 'X'
},
enableWindowSnap: true,
snapThreshold: 15
},
editing: {
fontSize: CONFIG_LIMITS.fontSize.default,
@@ -527,7 +525,6 @@ export const useConfigStore = defineStore('config', () => {
// 窗口吸附配置相关方法
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
setSnapThreshold: async (value: number) => await updateGeneralConfig('snapThreshold', value),
// 更新配置相关方法
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),

View File

@@ -3,13 +3,15 @@ import {computed, ref} from 'vue';
import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models';
import {useSystemStore} from './systemStore';
const SCRATCH_DOCUMENT_ID = 1; // 默认草稿文档ID
export const useDocumentStore = defineStore('document', () => {
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
// === 核心状态 ===
const documents = ref<Record<number, Document>>({});
const recentDocumentIds = ref<number[]>([SCRATCH_DOCUMENT_ID]);
const recentDocumentIds = ref<number[]>([DEFAULT_DOCUMENT_ID.value]);
const currentDocumentId = ref<number | null>(null);
const currentDocument = ref<Document | null>(null);
@@ -159,7 +161,7 @@ export const useDocumentStore = defineStore('document', () => {
const deleteDocument = async (docId: number): Promise<boolean> => {
try {
// 检查是否是默认文档使用ID判断
if (docId === SCRATCH_DOCUMENT_ID) {
if (docId === DEFAULT_DOCUMENT_ID.value) {
return false;
}
@@ -221,6 +223,7 @@ export const useDocumentStore = defineStore('document', () => {
};
return {
DEFAULT_DOCUMENT_ID,
// 状态
documents,
documentList,

View File

@@ -20,13 +20,14 @@ export const useSystemStore = defineStore('system', () => {
const environment = ref<SystemEnvironment | null>(null);
const isLoading = ref(false);
// 窗口置顶状态管理
const isWindowOnTop = ref<boolean>(false);
// 计算属性
const isWindows = computed(() => environment.value?.OS === 'windows');
const isMacOS = computed(() => environment.value?.OS === 'darwin');
const isLinux = computed(() => environment.value?.OS === 'linux');
// 获取标题栏高度
const titleBarHeight = computed(() => {
if (isWindows.value) return '32px';
@@ -49,10 +50,31 @@ export const useSystemStore = defineStore('system', () => {
}
};
// 设置窗口置顶状态
const setWindowOnTop = async (isPinned: boolean): Promise<void> => {
isWindowOnTop.value = isPinned;
try {
await runtime.Window.SetAlwaysOnTop(isPinned);
} catch (error) {
console.error('Failed to set window always on top:', error);
}
};
// 切换窗口置顶状态
const toggleWindowOnTop = async (): Promise<void> => {
await setWindowOnTop(!isWindowOnTop.value);
};
// 重置临时置顶状态不调用系统API
const resetWindowOnTop = (): void => {
isWindowOnTop.value = false;
};
return {
// 状态
environment,
isLoading,
isWindowOnTop,
// 计算属性
isWindows,
@@ -62,5 +84,14 @@ export const useSystemStore = defineStore('system', () => {
// 方法
initializeSystemInfo,
setWindowOnTop,
toggleWindowOnTop,
resetWindowOnTop,
};
}, {
persist: {
key: 'voidraft-system',
storage: localStorage,
pick: ['isWindowOnTop']
}
});

5
go.mod
View File

@@ -3,7 +3,6 @@ module voidraft
go 1.24.4
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/creativeprojects/go-selfupdate v1.5.0
github.com/go-git/go-git/v5 v5.16.2
github.com/knadh/koanf/parsers/json v1.0.0
@@ -11,6 +10,7 @@ require (
github.com/knadh/koanf/providers/structs v1.0.0
github.com/knadh/koanf/v2 v2.2.2
github.com/robertkrimen/otto v0.5.1
github.com/stretchr/testify v1.10.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.25
golang.org/x/net v0.43.0
golang.org/x/sys v0.35.0
@@ -22,12 +22,14 @@ require (
code.gitea.io/sdk/gitea v0.21.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
@@ -62,6 +64,7 @@ require (
github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.51.0 // indirect

View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"time"
"voidraft/internal/version"
)
// TabType 定义了制表符类型
@@ -69,8 +70,7 @@ type GeneralConfig struct {
StartAtLogin bool `json:"startAtLogin"` // 开机启动设置
// 窗口吸附设置
EnableWindowSnap bool `json:"enableWindowSnap"` // 是否启用窗口吸附功能
SnapThreshold int `json:"snapThreshold"` // 吸附距离阈值(像素)
EnableWindowSnap bool `json:"enableWindowSnap"` // 是否启用窗口吸附功能(阈值现在是自适应的)
// 全局热键设置
EnableGlobalHotkey bool `json:"enableGlobalHotkey"` // 是否启用全局热键
@@ -150,7 +150,6 @@ func NewDefaultAppConfig() *AppConfig {
EnableSystemTray: true,
StartAtLogin: false,
EnableWindowSnap: true, // 默认启用窗口吸附
SnapThreshold: 15, // 默认15像素的吸附阈值
EnableGlobalHotkey: false,
GlobalHotkey: HotkeyCombo{
Ctrl: false,
@@ -178,7 +177,7 @@ func NewDefaultAppConfig() *AppConfig {
SystemTheme: SystemThemeAuto,
},
Updates: UpdatesConfig{
Version: "1.3.0",
Version: version.Version,
AutoUpdate: true,
PrimarySource: UpdateSourceGitea,
BackupSource: UpdateSourceGithub,
@@ -207,7 +206,7 @@ func NewDefaultAppConfig() *AppConfig {
},
Metadata: ConfigMetadata{
LastUpdated: time.Now().Format(time.RFC3339),
Version: "1.2.0",
Version: version.Version,
},
}
}

View File

@@ -263,7 +263,7 @@ func (s *BackupService) PushToRemote() error {
// 创建提交
_, err = w.Commit(fmt.Sprintf("Backup %s", time.Now().Format("2006-01-02 15:04:05")), &git.CommitOptions{
Author: &object.Signature{
Name: "VoidRaft",
Name: "voidraft",
Email: "backup@voidraft.app",
When: time.Now(),
},

View File

@@ -1,318 +1,195 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"time"
"voidraft/internal/models"
"github.com/Masterminds/semver/v3"
jsonparser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
"github.com/wailsapp/wails/v3/pkg/services/log"
"os"
"path/filepath"
"time"
)
const (
// CurrentAppConfigVersion 当前应用配置版本
CurrentAppConfigVersion = "1.3.0"
// BackupFilePattern 备份文件名模式
// BackupFilePattern backup file name pattern
BackupFilePattern = "%s.backup.%s.json"
// 资源限制常量
// MaxConfigFileSize maximum config file size
MaxConfigFileSize = 10 * 1024 * 1024 // 10MB
MaxRecursionDepth = 50 // 最大递归深度
)
// Migratable 可迁移的配置接口
type Migratable interface {
GetVersion() string // 获取当前版本
SetVersion(string) // 设置版本
SetLastUpdated(string) // 设置最后更新时间
GetDefaultConfig() any // 获取默认配置
}
// ConfigMigrationService 配置迁移服务
type ConfigMigrationService[T Migratable] struct {
// ConfigMigrator elegant configuration migrator with automatic field detection
type ConfigMigrator struct {
logger *log.LogService
configDir string
configName string
targetVersion string
configPath string
}
// MigrationResult 迁移结果
// MigrationResult migration operation result
type MigrationResult struct {
Migrated, ConfigUpdated bool
FromVersion, ToVersion string
BackupPath string
Migrated bool `json:"migrated"` // Whether migration was performed
MissingFields []string `json:"missingFields"` // Fields that were missing
BackupPath string `json:"backupPath"` // Path to backup file
Description string `json:"description"` // Description of migration
}
// NewConfigMigrationService 创建配置迁移服务
func NewConfigMigrationService[T Migratable](
// NewConfigMigrator creates a new configuration migrator
func NewConfigMigrator(
logger *log.LogService,
configDir string,
configName, targetVersion, configPath string,
) *ConfigMigrationService[T] {
return &ConfigMigrationService[T]{
logger: orDefault(logger, log.New()),
configName, configPath string,
) *ConfigMigrator {
if logger == nil {
logger = log.New()
}
return &ConfigMigrator{
logger: logger,
configDir: configDir,
configName: configName,
targetVersion: targetVersion,
configPath: configPath,
}
}
// MigrateConfig 迁移配置文件
func (cms *ConfigMigrationService[T]) MigrateConfig(existingConfig *koanf.Koanf) (*MigrationResult, error) {
currentVersion := orDefault(existingConfig.String("metadata.version"), "0.0.0")
result := &MigrationResult{
FromVersion: currentVersion,
ToVersion: cms.targetVersion,
// AutoMigrate automatically detects and migrates missing configuration fields
func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) {
// Load default config into temporary koanf instance
defaultKoanf := koanf.New(".")
if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
return nil, fmt.Errorf("failed to load default config: %w", err)
}
if needsMigration, err := cms.needsMigration(currentVersion); err != nil {
return result, fmt.Errorf("version comparison failed: %w", err)
} else if !needsMigration {
// Detect missing fields
missingFields := cm.detectMissingFields(currentConfig.All(), defaultKoanf.All())
// Create result object
result := &MigrationResult{
MissingFields: missingFields,
Migrated: len(missingFields) > 0,
Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)),
}
// If no missing fields, return early
if !result.Migrated {
cm.logger.Info("No missing configuration fields detected")
return result, nil
}
// 资源检查和备份
if err := cms.checkResourceLimits(); err != nil {
return result, fmt.Errorf("resource limit check failed: %w", err)
}
if backupPath, err := cms.createBackupOptimized(); err != nil {
return result, fmt.Errorf("backup creation failed: %w", err)
// Only create backup if we actually need to migrate (has missing fields)
if len(missingFields) > 0 {
if backupPath, err := cm.createBackup(); err != nil {
cm.logger.Error("Failed to create backup", "error", err)
} else {
result.BackupPath = backupPath
}
// 自动恢复检查
cms.tryQuickRecovery(existingConfig)
// 执行迁移
if configUpdated, err := cms.performOptimizedMigration(existingConfig); err != nil {
return result, fmt.Errorf("migration failed: %w", err)
} else {
result.Migrated = true
result.ConfigUpdated = configUpdated
}
// Merge missing fields from default config
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
return result, fmt.Errorf("failed to merge default fields: %w", err)
}
// Save updated config
if err := cm.saveConfig(currentConfig); err != nil {
return result, fmt.Errorf("failed to save updated config: %w", err)
}
cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields))
return result, nil
}
// needsMigration 检查是否需要迁移
func (cms *ConfigMigrationService[T]) needsMigration(current string) (bool, error) {
currentVer, err := semver.NewVersion(current)
if err != nil {
return true, nil
}
targetVer, err := semver.NewVersion(cms.targetVersion)
if err != nil {
return false, fmt.Errorf("invalid target version: %s", cms.targetVersion)
}
return currentVer.LessThan(targetVer), nil
// detectMissingFields detects missing configuration fields
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
var missingFields []string
cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields)
return missingFields
}
// checkResourceLimits 检查资源限制
func (cms *ConfigMigrationService[T]) checkResourceLimits() error {
if info, err := os.Stat(cms.configPath); err == nil && info.Size() > MaxConfigFileSize {
return fmt.Errorf("config file size (%d bytes) exceeds limit (%d bytes)", info.Size(), MaxConfigFileSize)
// findMissingFieldsRecursive recursively finds missing fields
func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
for key, defaultVal := range defaultMap {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
currentVal, exists := currentMap[key]
if !exists {
// Field is completely missing
*missing = append(*missing, fullKey)
} else {
// Check nested structures
if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing)
} else {
// Current value is not a map but default is, structure mismatch
*missing = append(*missing, fullKey)
}
}
}
}
}
// mergeDefaultFields merges default values for missing fields into current config
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
for _, field := range missingFields {
defaultValue := defaultConfig.Get(field)
if defaultValue != nil {
current.Set(field, defaultValue)
cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue)
}
}
// Update last modified timestamp
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
return nil
}
// createBackupOptimized 优化的备份创建
func (cms *ConfigMigrationService[T]) createBackupOptimized() (string, error) {
if _, err := os.Stat(cms.configPath); os.IsNotExist(err) {
// createBackup creates a backup of the configuration file
func (cm *ConfigMigrator) createBackup() (string, error) {
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
return "", nil
}
configDir := cms.configDir
timestamp := time.Now().Format("20060102150405")
newBackupPath := filepath.Join(configDir, fmt.Sprintf(BackupFilePattern, cms.configName, timestamp))
backupPath := filepath.Join(cm.configDir, fmt.Sprintf(BackupFilePattern, cm.configName, timestamp))
// 单次扫描:删除旧备份并创建新备份
pattern := filepath.Join(configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
if matches, err := filepath.Glob(pattern); err == nil {
for _, oldBackup := range matches {
if oldBackup != newBackupPath {
os.Remove(oldBackup) // 忽略删除错误,继续处理
}
}
}
return newBackupPath, copyFile(cms.configPath, newBackupPath)
}
// tryQuickRecovery 快速恢复检查
func (cms *ConfigMigrationService[T]) tryQuickRecovery(existingConfig *koanf.Koanf) {
var testConfig T
if existingConfig.Unmarshal("", &testConfig) != nil {
cms.logger.Info("Config appears corrupted, attempting quick recovery")
if backupPath := cms.findLatestBackupQuick(); backupPath != "" {
if data, err := os.ReadFile(backupPath); err == nil {
existingConfig.Delete("")
existingConfig.Load(&BytesProvider{data}, jsonparser.Parser())
cms.logger.Info("Quick recovery successful")
}
}
}
}
// findLatestBackupQuick 快速查找最新备份(优化排序)
func (cms *ConfigMigrationService[T]) findLatestBackupQuick() string {
pattern := filepath.Join(cms.configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return ""
}
sort.Strings(matches) // 字典序排序,时间戳格式确保正确性
return matches[len(matches)-1]
}
// performOptimizedMigration 优化的迁移执行
func (cms *ConfigMigrationService[T]) performOptimizedMigration(existingConfig *koanf.Koanf) (bool, error) {
// 直接从koanf实例获取配置避免额外序列化
var currentConfig T
if err := existingConfig.Unmarshal("", &currentConfig); err != nil {
return false, fmt.Errorf("unmarshal existing config failed: %w", err)
}
defaultConfig, ok := currentConfig.GetDefaultConfig().(T)
if !ok {
return false, fmt.Errorf("default config type mismatch")
}
return cms.mergeInPlace(existingConfig, currentConfig, defaultConfig)
}
// mergeInPlace 就地合并配置
func (cms *ConfigMigrationService[T]) mergeInPlace(existingConfig *koanf.Koanf, currentConfig, defaultConfig T) (bool, error) {
// 创建临时合并实例
mergeKoanf := koanf.New(".")
// 使用快速加载链
if err := chainLoad(mergeKoanf,
func() error { return mergeKoanf.Load(structs.Provider(defaultConfig, "json"), nil) },
func() error {
return mergeKoanf.Load(structs.Provider(currentConfig, "json"), nil,
koanf.WithMergeFunc(cms.fastMerge))
},
); err != nil {
return false, fmt.Errorf("config merge failed: %w", err)
}
// 更新元数据
mergeKoanf.Set("metadata.version", cms.targetVersion)
mergeKoanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
// 一次性序列化和原子写入
configBytes, err := mergeKoanf.Marshal(jsonparser.Parser())
data, err := os.ReadFile(cm.configPath)
if err != nil {
return false, fmt.Errorf("marshal config failed: %w", err)
return "", fmt.Errorf("failed to read config file: %w", err)
}
if err := os.WriteFile(backupPath, data, 0644); err != nil {
return "", fmt.Errorf("failed to create backup: %w", err)
}
cm.logger.Info("Configuration backup created", "path", backupPath)
return backupPath, nil
}
// saveConfig saves configuration to file
func (cm *ConfigMigrator) saveConfig(config *koanf.Koanf) error {
configBytes, err := config.Marshal(jsonparser.Parser())
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if len(configBytes) > MaxConfigFileSize {
return false, fmt.Errorf("merged config size exceeds limit")
return fmt.Errorf("config size (%d bytes) exceeds limit (%d bytes)", len(configBytes), MaxConfigFileSize)
}
// 原子写入
return true, cms.atomicWrite(existingConfig, configBytes)
}
// atomicWrite 原子写入操作
func (cms *ConfigMigrationService[T]) atomicWrite(existingConfig *koanf.Koanf, configBytes []byte) error {
tempPath := cms.configPath + ".tmp"
// Atomic write
tempPath := cm.configPath + ".tmp"
if err := os.WriteFile(tempPath, configBytes, 0644); err != nil {
return fmt.Errorf("write temp config failed: %w", err)
return fmt.Errorf("failed to write temp config: %w", err)
}
if err := os.Rename(tempPath, cms.configPath); err != nil {
if err := os.Rename(tempPath, cm.configPath); err != nil {
os.Remove(tempPath)
return fmt.Errorf("atomic rename failed: %w", err)
return fmt.Errorf("failed to rename temp config: %w", err)
}
// 重新加载到原实例
existingConfig.Delete("")
return existingConfig.Load(&BytesProvider{configBytes}, jsonparser.Parser())
}
// fastMerge 快速合并函数
func (cms *ConfigMigrationService[T]) fastMerge(src, dest map[string]interface{}) error {
return cms.fastMergeRecursive(src, dest, 0)
}
// fastMergeRecursive 快速递归合并
func (cms *ConfigMigrationService[T]) fastMergeRecursive(src, dest map[string]interface{}, depth int) error {
if depth > MaxRecursionDepth {
return fmt.Errorf("recursion depth exceeded")
}
for key, srcVal := range src {
if destVal, exists := dest[key]; exists {
// 优先检查map类型最常见情况
if srcMap, srcOK := srcVal.(map[string]interface{}); srcOK {
if destMap, destOK := destVal.(map[string]interface{}); destOK {
if err := cms.fastMergeRecursive(srcMap, destMap, depth+1); err != nil {
return err
}
continue
}
}
// 快速空值检查(避免反射)
if srcVal == nil || srcVal == "" || srcVal == 0 {
continue
}
}
dest[key] = srcVal
}
return nil
}
// BytesProvider 轻量字节提供器
type BytesProvider struct{ data []byte }
func (bp *BytesProvider) ReadBytes() ([]byte, error) { return bp.data, nil }
func (bp *BytesProvider) Read() (map[string]interface{}, error) {
var result map[string]interface{}
return result, json.Unmarshal(bp.data, &result)
}
// 工具函数
func orDefault[T any](value, defaultValue T) T {
var zero T
if reflect.DeepEqual(value, zero) {
return defaultValue
}
return value
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
func chainLoad(k *koanf.Koanf, loaders ...func() error) error {
for _, loader := range loaders {
if err := loader(); err != nil {
return err
}
}
return nil
}
// 工厂函数
func NewAppConfigMigrationService(logger *log.LogService, configDir, settingsPath string) *ConfigMigrationService[*models.AppConfig] {
return NewConfigMigrationService[*models.AppConfig](
logger, configDir, "settings", CurrentAppConfigVersion, settingsPath)
}

View File

@@ -0,0 +1,167 @@
package services
import (
"encoding/json"
jsonparser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/stretchr/testify/assert"
"github.com/wailsapp/wails/v3/pkg/services/log"
"os"
"path/filepath"
"testing"
)
// TestConfig represents a simplified config structure for testing
type TestConfig struct {
App struct {
Name string `json:"name"`
Version string `json:"version"`
Theme string `json:"theme"`
} `json:"app"`
User struct {
Name string `json:"name"`
Email string `json:"email"`
Settings struct {
AutoSave bool `json:"autoSave"`
Language string `json:"language"`
NewSetting bool `json:"newSetting"` // This field will be missing in old config
NewSetting2 string `json:"newSetting2"` // This field will be missing in old config
} `json:"settings"`
} `json:"user"`
NewSection struct {
Enabled bool `json:"enabled"`
Value string `json:"value"`
} `json:"newSection"` // This entire section will be missing in old config
}
// createTestConfig creates a test configuration file
func createTestConfig(t *testing.T, tempDir string) string {
// Old config without some fields
oldConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "TestApp",
"version": "1.0.0",
"theme": "dark",
},
"user": map[string]interface{}{
"name": "Test User",
"email": "test@example.com",
"settings": map[string]interface{}{
"autoSave": true,
"language": "en",
// Missing newSetting and newSetting2
},
},
// Missing newSection
}
// Create config file
configPath := filepath.Join(tempDir, "config.json")
jsonData, err := json.MarshalIndent(oldConfig, "", " ")
if err != nil {
t.Fatalf("Failed to marshal test config: %v", err)
}
err = os.WriteFile(configPath, jsonData, 0644)
if err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
return configPath
}
// TestConfigMigrator_AutoMigrate tests the ConfigMigrator's AutoMigrate functionality
func TestConfigMigrator_AutoMigrate(t *testing.T) {
// Create temp directory for test
tempDir, err := os.MkdirTemp("", "config_migrator_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create test config file
configPath := createTestConfig(t, tempDir)
// Create logger
logger := log.New()
// Create config migrator
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
// Create koanf instance and load the config
k := koanf.New(".")
fileProvider := file.Provider(configPath)
jsonParser := jsonparser.Parser()
if err := k.Load(fileProvider, jsonParser); err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Create default config with all fields
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true // New field
defaultConfig.User.Settings.NewSetting2 = "value" // New field
defaultConfig.NewSection.Enabled = true // New section
defaultConfig.NewSection.Value = "new section" // New section
// Run auto migration
result, err := migrator.AutoMigrate(defaultConfig, k)
if err != nil {
t.Fatalf("Failed to auto migrate: %v", err)
}
// Assertions
assert.True(t, result.Migrated, "Migration should have been performed")
// 打印检测到的缺失字段,便于分析
t.Logf("Detected fields: %v", result.MissingFields)
// 验证检测到了正确数量的字段 - 实际检测到4个
assert.Equal(t, 4, len(result.MissingFields), "Should have detected 4 missing fields")
// 期望检测到的缺失字段
expectedFields := map[string]bool{
"user.settings.newSetting": true,
"user.settings.newSetting2": true,
"newSection.enabled": true,
"newSection.value": true,
}
// 验证所有预期的字段都被检测到了
for _, field := range result.MissingFields {
_, expected := expectedFields[field]
assert.True(t, expected, "Field %s was detected but not expected", field)
}
// 验证所有检测到的字段都在预期之内
for expectedField := range expectedFields {
found := false
for _, field := range result.MissingFields {
if field == expectedField {
found = true
break
}
}
assert.True(t, found, "Expected field %s was not detected", expectedField)
}
// Verify that the fields were actually added to the config
assert.True(t, k.Bool("user.settings.newSetting"), "newSetting should be added with correct value")
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "newSetting2 should be added with correct value")
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
// Check that backup was created
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
if err != nil {
t.Fatalf("Failed to list backup files: %v", err)
}
assert.Equal(t, 1, len(backupFiles), "One backup file should have been created")
}

View File

@@ -288,25 +288,6 @@ func deepCopyValue(src, dst reflect.Value) {
}
}
// deepCopyConfig 保留原有的JSON深拷贝方法作为备用
func deepCopyConfig(src *models.AppConfig) *models.AppConfig {
if src == nil {
return nil
}
jsonBytes, err := json.Marshal(src)
if err != nil {
return src
}
var dst models.AppConfig
if err := json.Unmarshal(jsonBytes, &dst); err != nil {
return src
}
return &dst
}
// debounceNotify 防抖通知
func (cns *ConfigNotificationService) debounceNotify(listener *ConfigListener, oldConfig, newConfig *models.AppConfig) {
listener.mu.Lock()
@@ -473,16 +454,16 @@ func CreateBackupConfigListener(name string, callback func(config *models.GitBac
}
// CreateWindowSnapConfigListener 创建窗口吸附配置监听器
func CreateWindowSnapConfigListener(name string, callback func(enabled bool, threshold int) error) *ConfigListener {
func CreateWindowSnapConfigListener(name string, callback func(enabled bool) error) *ConfigListener {
return &ConfigListener{
Name: name,
ChangeType: ConfigChangeTypeWindowSnap,
Callback: func(changeType ConfigChangeType, oldConfig, newConfig *models.AppConfig) error {
if newConfig == nil {
defaultConfig := models.NewDefaultAppConfig()
return callback(defaultConfig.General.EnableWindowSnap, defaultConfig.General.SnapThreshold)
return callback(defaultConfig.General.EnableWindowSnap)
}
return callback(newConfig.General.EnableWindowSnap, newConfig.General.SnapThreshold)
return callback(newConfig.General.EnableWindowSnap)
},
DebounceDelay: 200 * time.Millisecond,
GetConfigFunc: func(k *koanf.Koanf) *models.AppConfig {

View File

@@ -27,8 +27,9 @@ type ConfigService struct {
// 配置通知服务
notificationService *ConfigNotificationService
// 配置迁移服务
migrationService *ConfigMigrationService[*models.AppConfig]
// 配置迁移器
configMigrator *ConfigMigrator
}
// ConfigError 配置错误
@@ -71,12 +72,14 @@ func NewConfigService(logger *log.LogService) *ConfigService {
configDir: configDir,
settingsPath: settingsPath,
koanf: koanf.New("."),
migrationService: NewAppConfigMigrationService(logger, configDir, settingsPath),
}
// 初始化配置通知服务
cs.notificationService = NewConfigNotificationService(cs.koanf, logger)
// 初始化配置迁移器
cs.configMigrator = NewConfigMigrator(logger, configDir, "settings", settingsPath)
cs.initConfig()
// 启动配置文件监听
@@ -106,23 +109,36 @@ func (cs *ConfigService) initConfig() error {
return cs.createDefaultConfig()
}
// 配置文件存在,先加载现有配置
// 检查并自动迁移配置
cs.checkConfigMigration()
// 配置文件存在,直接加载现有配置
cs.fileProvider = file.Provider(cs.settingsPath)
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
return &ConfigError{Operation: "load_config_file", Err: err}
}
// 检查并执行配置迁移
if cs.migrationService != nil {
result, err := cs.migrationService.MigrateConfig(cs.koanf)
if err != nil {
return &ConfigError{Operation: "migrate_config", Err: err}
return nil
}
if result.Migrated && result.ConfigUpdated {
// 迁移完成且配置已更新,重新创建文件提供器以监听新文件
cs.fileProvider = file.Provider(cs.settingsPath)
// checkConfigMigration 检查配置迁移
func (cs *ConfigService) checkConfigMigration() error {
if cs.configMigrator == nil {
return nil
}
defaultConfig := models.NewDefaultAppConfig()
result, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf)
if err != nil {
cs.logger.Error("Failed to check config migration", "error", err)
return err
}
if result != nil && result.Migrated {
cs.logger.Info("Config migration performed",
"fields", result.MissingFields,
"backup", result.BackupPath)
}
return nil
@@ -309,7 +325,7 @@ func (cs *ConfigService) SetBackupConfigChangeCallback(callback func(config *mod
}
// SetWindowSnapConfigChangeCallback 设置窗口吸附配置变更回调
func (cs *ConfigService) SetWindowSnapConfigChangeCallback(callback func(enabled bool, threshold int) error) error {
func (cs *ConfigService) SetWindowSnapConfigChangeCallback(callback func(enabled bool) error) error {
cs.mu.Lock()
defer cs.mu.Unlock()
@@ -326,3 +342,13 @@ func (cs *ConfigService) ServiceShutdown() error {
}
return nil
}
// GetConfigDir 获取配置目录
func (cs *ConfigService) GetConfigDir() string {
return cs.configDir
}
// GetSettingsPath 获取设置文件路径
func (cs *ConfigService) GetSettingsPath() string {
return cs.settingsPath
}

View File

@@ -116,8 +116,8 @@ func NewServiceManager() *ServiceManager {
}
// 设置窗口吸附配置变更回调
err = configService.SetWindowSnapConfigChangeCallback(func(enabled bool, threshold int) error {
return windowSnapService.OnWindowSnapConfigChanged(enabled, threshold)
err = configService.SetWindowSnapConfigChangeCallback(func(enabled bool) error {
return windowSnapService.OnWindowSnapConfigChanged(enabled)
})
if err != nil {
panic(err)

View File

@@ -1,13 +1,13 @@
package services
import (
"context"
"math"
"sync"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
@@ -20,33 +20,20 @@ type WindowSnapService struct {
mu sync.RWMutex
// 吸附配置
snapThreshold int // 吸附触发的阈值距离(像素)
snapEnabled bool // 是否启用窗口吸附功能
// 定时器控制
snapTicker *time.Ticker
ctx context.Context
cancel context.CancelFunc
// 自适应阈值参数
baseThresholdRatio float64 // 基础阈值比例
minThreshold int // 最小阈值(像素)
maxThreshold int // 最大阈值(像素)
// 性能优化相关
lastMainWindowPos models.WindowPosition // 缓存主窗口上次位置
changedWindows map[int64]bool // 记录哪些窗口发生了变化
skipFrames int // 跳帧计数器,用于降低检测频率
// 位置缓存
lastMainWindowPos models.WindowPosition // 缓存主窗口位置
lastMainWindowSize [2]int // 缓存主窗口尺寸 [width, height]
// 监听的窗口列表
managedWindows map[int64]*SnapWindowInfo // documentID -> SnapWindowInfo
}
// SnapWindowInfo 吸附窗口信息
type SnapWindowInfo struct {
Window *application.WebviewWindow
DocumentID int64
Title string
IsSnapped bool // 是否处于吸附状态
SnapOffset models.SnapPosition // 与主窗口的相对位置偏移
SnapEdge models.SnapEdge // 吸附的边缘类型
LastPos models.WindowPosition // 上一次记录的窗口位置
MoveTime time.Time // 上次移动时间,用于判断移动速度
// 管理的窗口
managedWindows map[int64]*models.WindowInfo // documentID -> WindowInfo
windowRefs map[int64]*application.WebviewWindow // documentID -> Window引用
}
// NewWindowSnapService 创建新的窗口吸附服务实例
@@ -58,21 +45,20 @@ func NewWindowSnapService(logger *log.LogService, configService *ConfigService)
// 从配置获取窗口吸附设置
config, err := configService.GetConfig()
snapEnabled := true // 默认启用
snapThreshold := 15 // 默认阈值
if err == nil {
snapEnabled = config.General.EnableWindowSnap
snapThreshold = config.General.SnapThreshold
}
return &WindowSnapService{
logger: logger,
configService: configService,
snapThreshold: snapThreshold,
snapEnabled: snapEnabled,
managedWindows: make(map[int64]*SnapWindowInfo),
changedWindows: make(map[int64]bool),
skipFrames: 0,
baseThresholdRatio: 0.025, // 2.5%的主窗口宽度作为基础阈值
minThreshold: 8, // 最小8像素小屏幕保底
maxThreshold: 40, // 最大40像素大屏幕上限
managedWindows: make(map[int64]*models.WindowInfo),
windowRefs: make(map[int64]*application.WebviewWindow),
}
}
@@ -81,11 +67,13 @@ func (wss *WindowSnapService) SetAppReferences(app *application.App, mainWindow
wss.app = app
wss.mainWindow = mainWindow
// 初始化上下文用于控制goroutine的生命周期
wss.ctx, wss.cancel = context.WithCancel(context.Background())
// 初始化主窗口位置缓存
wss.updateMainWindowCache()
// 启动窗口吸附监听
wss.StartWindowSnapMonitor()
// 设置主窗口移动事件监听
if mainWindow != nil {
wss.setupMainWindowEvents()
}
}
// RegisterWindow 注册需要吸附管理的窗口
@@ -93,16 +81,24 @@ func (wss *WindowSnapService) RegisterWindow(documentID int64, window *applicati
wss.mu.Lock()
defer wss.mu.Unlock()
wss.managedWindows[documentID] = &SnapWindowInfo{
Window: window,
// 获取初始位置
x, y := window.Position()
windowInfo := &models.WindowInfo{
DocumentID: documentID,
Title: title,
IsSnapped: false,
SnapOffset: models.SnapPosition{X: 0, Y: 0},
SnapEdge: models.SnapEdgeNone,
LastPos: models.WindowPosition{X: 0, Y: 0},
LastPos: models.WindowPosition{X: x, Y: y},
MoveTime: time.Now(),
}
wss.managedWindows[documentID] = windowInfo
wss.windowRefs[documentID] = window
// 为窗口设置移动事件监听
wss.setupWindowEvents(window, windowInfo)
}
// UnregisterWindow 取消注册窗口
@@ -111,7 +107,7 @@ func (wss *WindowSnapService) UnregisterWindow(documentID int64) {
defer wss.mu.Unlock()
delete(wss.managedWindows, documentID)
delete(wss.changedWindows, documentID)
delete(wss.windowRefs, documentID)
}
// SetSnapEnabled 设置是否启用窗口吸附
@@ -133,196 +129,193 @@ func (wss *WindowSnapService) SetSnapEnabled(enabled bool) {
windowInfo.SnapEdge = models.SnapEdgeNone
}
}
// 停止定时器
if wss.snapTicker != nil {
wss.snapTicker.Stop()
wss.snapTicker = nil
}
} else if wss.snapTicker == nil && wss.app != nil {
// 重新启动定时器
wss.StartWindowSnapMonitor()
}
}
// SetSnapThreshold 设置窗口吸附阈值
func (wss *WindowSnapService) SetSnapThreshold(threshold int) {
wss.mu.Lock()
defer wss.mu.Unlock()
if threshold <= 0 || wss.snapThreshold == threshold {
return
// calculateAdaptiveThreshold 计算自适应吸附阈值
func (wss *WindowSnapService) calculateAdaptiveThreshold() int {
// 基于主窗口宽度计算阈值
mainWidth := wss.lastMainWindowSize[0]
if mainWidth == 0 {
return wss.minThreshold // 默认最小值
}
wss.snapThreshold = threshold
// 计算基础阈值主窗口宽度的2.5%
adaptiveThreshold := int(float64(mainWidth) * wss.baseThresholdRatio)
// 限制在最小和最大值之间
if adaptiveThreshold < wss.minThreshold {
return wss.minThreshold
}
if adaptiveThreshold > wss.maxThreshold {
return wss.maxThreshold
}
return adaptiveThreshold
}
// GetCurrentThreshold 获取当前自适应阈值(用于调试或显示)
func (wss *WindowSnapService) GetCurrentThreshold() int {
wss.mu.RLock()
defer wss.mu.RUnlock()
return wss.calculateAdaptiveThreshold()
}
// OnWindowSnapConfigChanged 处理窗口吸附配置变更
func (wss *WindowSnapService) OnWindowSnapConfigChanged(enabled bool, threshold int) error {
func (wss *WindowSnapService) OnWindowSnapConfigChanged(enabled bool) error {
wss.SetSnapEnabled(enabled)
wss.SetSnapThreshold(threshold)
// 阈值现在是自适应的,无需手动设置
return nil
}
// StartWindowSnapMonitor 启动窗口吸附监听
func (wss *WindowSnapService) StartWindowSnapMonitor() {
// 如果定时器已存在,先停止它
if wss.snapTicker != nil {
wss.snapTicker.Stop()
// setupMainWindowEvents 设置主窗口事件监听
func (wss *WindowSnapService) setupMainWindowEvents() {
// 监听主窗口移动事件
wss.mainWindow.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) {
wss.onMainWindowMoved()
})
}
// 只有在吸附功能启用时才启动监听
// setupWindowEvents 为子窗口设置事件监听
func (wss *WindowSnapService) setupWindowEvents(window *application.WebviewWindow, windowInfo *models.WindowInfo) {
// 监听子窗口移动事件
window.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) {
wss.onChildWindowMoved(window, windowInfo)
})
}
// updateMainWindowCache 更新主窗口缓存
func (wss *WindowSnapService) updateMainWindowCache() {
if wss.mainWindow == nil {
return
}
x, y := wss.mainWindow.Position()
w, h := wss.mainWindow.Size()
wss.lastMainWindowPos = models.WindowPosition{X: x, Y: y}
wss.lastMainWindowSize = [2]int{w, h}
}
// onMainWindowMoved 主窗口移动事件处理
func (wss *WindowSnapService) onMainWindowMoved() {
if !wss.snapEnabled {
return
}
// 创建新的定时器20fps 以优化性能
wss.snapTicker = time.NewTicker(50 * time.Millisecond)
// 启动goroutine持续监听窗口位置
go func() {
for {
select {
case <-wss.snapTicker.C:
wss.checkAndApplySnapping()
case <-wss.ctx.Done():
// 上下文取消时停止goroutine
return
}
}
}()
}
// checkAndApplySnapping 检测并应用窗口吸附(性能优化版本)
func (wss *WindowSnapService) checkAndApplySnapping() {
if !wss.snapEnabled {
return
}
// 性能优化每3帧执行一次完整检测其他时间只处理变化的窗口
wss.skipFrames++
fullCheck := wss.skipFrames%3 == 0
wss.mu.Lock()
defer wss.mu.Unlock()
// 检查主窗口是否存在且可见
if wss.mainWindow == nil || !wss.isMainWindowAvailable() {
// 主窗口不可用,解除所有吸附
// 更新主窗口缓存
wss.updateMainWindowCache()
// 只更新已吸附窗口的位置,无需重新检测所有窗口
for _, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
wss.updateSnappedWindowPosition(windowInfo)
}
}
// 清空变化记录
wss.changedWindows = make(map[int64]bool)
}
// onChildWindowMoved 子窗口移动事件处理
func (wss *WindowSnapService) onChildWindowMoved(window *application.WebviewWindow, windowInfo *models.WindowInfo) {
if !wss.snapEnabled {
return
}
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
wss.mu.Lock()
defer wss.mu.Unlock()
// 检查主窗口是否移动了
mainWindowMoved := mainPos.X != wss.lastMainWindowPos.X || mainPos.Y != wss.lastMainWindowPos.Y
if mainWindowMoved {
wss.lastMainWindowPos = mainPos
// 主窗口移动了,标记所有吸附的窗口为变化
for documentID, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
wss.changedWindows[documentID] = true
}
}
// 获取当前位置
x, y := window.Position()
currentPos := models.WindowPosition{X: x, Y: y}
// 检查是否真的移动了(避免无效触发)
if currentPos.X == windowInfo.LastPos.X && currentPos.Y == windowInfo.LastPos.Y {
return
}
for documentID, windowInfo := range wss.managedWindows {
currentPos, _ := wss.getWindowPosition(windowInfo.Window)
// 检查窗口是否移动了
hasMoved := currentPos.X != windowInfo.LastPos.X || currentPos.Y != windowInfo.LastPos.Y
if hasMoved {
// 保存上次移动时间用于防抖检测
lastMoveTime := windowInfo.MoveTime
windowInfo.MoveTime = time.Now()
windowInfo.LastPos = currentPos
wss.changedWindows[documentID] = true
}
// 性能优化:只处理变化的窗口或进行完整检查时
if !fullCheck && !wss.changedWindows[documentID] {
continue
}
if windowInfo.IsSnapped {
// 窗口已吸附检查是否需要更新位置或解除吸附
wss.handleSnappedWindow(windowInfo, mainPos, currentPos)
// 已吸附窗口:检查是否被用户拖拽解除吸附
wss.handleSnappedWindow(window, windowInfo, currentPos)
// 对于已吸附窗口,总是更新为当前位置
windowInfo.LastPos = currentPos
} else {
// 窗口未吸附检查是否应该吸附
wss.handleUnsnappedWindow(windowInfo)
// 未吸附窗口:检查是否应该吸附
isSnapped := wss.handleUnsnappedWindow(window, windowInfo, currentPos, lastMoveTime)
if !isSnapped {
// 如果没有吸附,更新为当前位置
windowInfo.LastPos = currentPos
}
// 处理完成后清除变化标记
delete(wss.changedWindows, documentID)
// 如果成功吸附位置已在handleUnsnappedWindow中更新
}
}
// isMainWindowAvailable 检查主窗口是否可用
func (wss *WindowSnapService) isMainWindowAvailable() bool {
if wss.mainWindow == nil {
return false
// updateSnappedWindowPosition 更新已吸附窗口的位置
func (wss *WindowSnapService) updateSnappedWindowPosition(windowInfo *models.WindowInfo) {
// 计算新的目标位置(基于主窗口新位置)
expectedX := wss.lastMainWindowPos.X + windowInfo.SnapOffset.X
expectedY := wss.lastMainWindowPos.Y + windowInfo.SnapOffset.Y
// 查找对应的window对象并移动
if window, exists := wss.windowRefs[windowInfo.DocumentID]; exists {
window.SetPosition(expectedX, expectedY)
}
// 检查主窗口是否可见和正常状态
return wss.mainWindow.IsVisible()
windowInfo.LastPos = models.WindowPosition{X: expectedX, Y: expectedY}
}
// handleSnappedWindow 处理已吸附窗口
func (wss *WindowSnapService) handleSnappedWindow(windowInfo *SnapWindowInfo, mainPos models.WindowPosition, currentPos models.WindowPosition) {
// 计算预期位置基于主窗口的新位置
expectedX := mainPos.X + windowInfo.SnapOffset.X
expectedY := mainPos.Y + windowInfo.SnapOffset.Y
// handleSnappedWindow 处理已吸附窗口的移动
func (wss *WindowSnapService) handleSnappedWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition) {
// 计算预期位置
expectedX := wss.lastMainWindowPos.X + windowInfo.SnapOffset.X
expectedY := wss.lastMainWindowPos.Y + windowInfo.SnapOffset.Y
// 计算当前位置与预期位置的距离
// 计算实际位置与预期位置的距离
distanceX := math.Abs(float64(currentPos.X - expectedX))
distanceY := math.Abs(float64(currentPos.Y - expectedY))
maxDistance := math.Max(distanceX, distanceY)
// 检测是否为用户主动拖拽:如果窗口移动幅度超过阈值且是最近移动的
userDragThreshold := float64(wss.snapThreshold)
isUserDrag := maxDistance > userDragThreshold && time.Since(windowInfo.MoveTime) < 100*time.Millisecond
// 用户拖拽检测:距离超过阈值且移动很快
userDragThreshold := float64(wss.calculateAdaptiveThreshold())
isUserDrag := maxDistance > userDragThreshold && time.Since(windowInfo.MoveTime) < 50*time.Millisecond
if isUserDrag {
// 用户主动拖拽,立即解除吸附
// 用户主动拖拽,解除吸附
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
return
}
// 对于主窗口移动导致的位置变化,立即跟随且不解除吸附
if maxDistance > 0 {
// 直接调整到预期位置,不使用平滑移动以提高响应速度
windowInfo.Window.SetPosition(expectedX, expectedY)
windowInfo.LastPos = models.WindowPosition{X: expectedX, Y: expectedY}
}
}
// handleUnsnappedWindow 处理未吸附窗口
func (wss *WindowSnapService) handleUnsnappedWindow(windowInfo *SnapWindowInfo) {
// 检查是否应该吸附到主窗口
should, snapEdge := wss.shouldSnapToMainWindow(windowInfo)
// handleUnsnappedWindow 处理未吸附窗口的移动,返回是否成功吸附
func (wss *WindowSnapService) handleUnsnappedWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition, lastMoveTime time.Time) bool {
// 检查是否应该吸附
should, snapEdge := wss.shouldSnapToMainWindow(window, windowInfo, currentPos, lastMoveTime)
if should {
// 获取主窗口位置用于计算偏移量
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
// 设置吸附状态
windowInfo.IsSnapped = true
windowInfo.SnapEdge = snapEdge
// 执行即时吸附,产生明显的吸附效果
wss.snapWindowToMainWindow(windowInfo, snapEdge)
// 执行吸附移动
targetPos := wss.calculateSnapPosition(snapEdge, currentPos, window)
window.SetPosition(targetPos.X, targetPos.Y)
// 重新获取吸附后的位置来计算偏移量
newPos, _ := wss.getWindowPosition(windowInfo.Window)
windowInfo.SnapOffset.X = newPos.X - mainPos.X
windowInfo.SnapOffset.Y = newPos.Y - mainPos.Y
windowInfo.LastPos = newPos
// 计算并保存偏移量
windowInfo.SnapOffset.X = targetPos.X - wss.lastMainWindowPos.X
windowInfo.SnapOffset.Y = targetPos.Y - wss.lastMainWindowPos.Y
// 更新位置为吸附后的位置
windowInfo.LastPos = targetPos
return true
}
return false
}
// getWindowPosition 获取窗口的位置
@@ -331,269 +324,160 @@ func (wss *WindowSnapService) getWindowPosition(window *application.WebviewWindo
return models.WindowPosition{X: x, Y: y}, true
}
// shouldSnapToMainWindow 检查窗口是否应该吸附到主窗口(支持角落吸附)
func (wss *WindowSnapService) shouldSnapToMainWindow(windowInfo *SnapWindowInfo) (bool, models.SnapEdge) {
// 降低防抖时间,提高吸附响应速度
if time.Since(windowInfo.MoveTime) < 50*time.Millisecond {
// shouldSnapToMainWindow 优化版吸附检测
func (wss *WindowSnapService) shouldSnapToMainWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition, lastMoveTime time.Time) (bool, models.SnapEdge) {
// 防抖:移动太快时不检测,
timeSinceLastMove := time.Since(lastMoveTime)
if timeSinceLastMove < 30*time.Millisecond && timeSinceLastMove > 0 {
return false, models.SnapEdgeNone
}
// 获取两个窗口位置
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
windowPos, _ := wss.getWindowPosition(windowInfo.Window)
// 使用缓存的主窗口位置和尺寸
if wss.lastMainWindowSize[0] == 0 || wss.lastMainWindowSize[1] == 0 {
// 主窗口缓存未初始化,立即更新
wss.updateMainWindowCache()
}
// 获取主窗口尺寸
mainWidth, mainHeight := wss.mainWindow.Size()
mainPos := wss.lastMainWindowPos
mainWidth := wss.lastMainWindowSize[0]
mainHeight := wss.lastMainWindowSize[1]
// 获取子窗口尺寸
windowWidth, windowHeight := windowInfo.Window.Size()
windowWidth, windowHeight := window.Size()
// 计算各个边缘的距离
threshold := float64(wss.snapThreshold)
cornerThreshold := threshold * 1.5 // 角落吸附需要更大的阈值
// 自适应阈值计算
threshold := float64(wss.calculateAdaptiveThreshold())
cornerThreshold := threshold * 1.5
// 主窗口的四个边界
mainLeft := mainPos.X
mainTop := mainPos.Y
mainRight := mainPos.X + mainWidth
mainBottom := mainPos.Y + mainHeight
// 计算边界
mainLeft, mainTop := mainPos.X, mainPos.Y
mainRight, mainBottom := mainPos.X+mainWidth, mainPos.Y+mainHeight
// 子窗口的四个边界
windowLeft := windowPos.X
windowTop := windowPos.Y
windowRight := windowPos.X + windowWidth
windowBottom := windowPos.Y + windowHeight
windowLeft, windowTop := currentPos.X, currentPos.Y
windowRight, windowBottom := currentPos.X+windowWidth, currentPos.Y+windowHeight
// 存储每个边缘的吸附信息
type snapCandidate struct {
// 简化的距离计算结构
type snapCheck struct {
edge models.SnapEdge
distance float64
overlap float64 // 重叠度,用于优先级判断
isCorner bool // 是否为角落吸附
priority int // 1=角落, 2=边缘
}
var candidates []snapCandidate
var bestSnap *snapCheck
// ==== 检查角落吸附(优先级最高) ====
// 检查角落吸附(优先级1
cornerChecks := []struct {
edge models.SnapEdge
dx int
dy int
}{
{models.SnapEdgeTopRight, mainRight - windowLeft, mainTop - windowBottom},
{models.SnapEdgeBottomRight, mainRight - windowLeft, mainBottom - windowTop},
{models.SnapEdgeBottomLeft, mainLeft - windowRight, mainBottom - windowTop},
{models.SnapEdgeTopLeft, mainLeft - windowRight, mainTop - windowBottom},
}
// 1. 右上角 (SnapEdgeTopRight)
distToTopRight := math.Sqrt(math.Pow(float64(mainRight-windowLeft), 2) + math.Pow(float64(mainTop-windowBottom), 2))
if distToTopRight <= cornerThreshold {
// 检查是否接近右上角区域
horizDist := math.Abs(float64(mainRight - windowLeft))
vertDist := math.Abs(float64(mainTop - windowBottom))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeTopRight, distToTopRight, 100, true})
for _, check := range cornerChecks {
dist := math.Sqrt(float64(check.dx*check.dx + check.dy*check.dy))
if dist <= cornerThreshold {
if bestSnap == nil || dist < bestSnap.distance {
bestSnap = &snapCheck{check.edge, dist, 1}
}
}
}
// 2. 右下角 (SnapEdgeBottomRight)
distToBottomRight := math.Sqrt(math.Pow(float64(mainRight-windowLeft), 2) + math.Pow(float64(mainBottom-windowTop), 2))
if distToBottomRight <= cornerThreshold {
horizDist := math.Abs(float64(mainRight - windowLeft))
vertDist := math.Abs(float64(mainBottom - windowTop))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeBottomRight, distToBottomRight, 100, true})
// 如果没有角落吸附检查边缘吸附优先级2
if bestSnap == nil {
edgeChecks := []struct {
edge models.SnapEdge
distance float64
}{
{models.SnapEdgeRight, math.Abs(float64(mainRight - windowLeft))},
{models.SnapEdgeLeft, math.Abs(float64(mainLeft - windowRight))},
{models.SnapEdgeBottom, math.Abs(float64(mainBottom - windowTop))},
{models.SnapEdgeTop, math.Abs(float64(mainTop - windowBottom))},
}
for _, check := range edgeChecks {
if check.distance <= threshold {
if bestSnap == nil || check.distance < bestSnap.distance {
bestSnap = &snapCheck{check.edge, check.distance, 2}
}
}
}
}
// 3. 左下角 (SnapEdgeBottomLeft)
distToBottomLeft := math.Sqrt(math.Pow(float64(mainLeft-windowRight), 2) + math.Pow(float64(mainBottom-windowTop), 2))
if distToBottomLeft <= cornerThreshold {
horizDist := math.Abs(float64(mainLeft - windowRight))
vertDist := math.Abs(float64(mainBottom - windowTop))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeBottomLeft, distToBottomLeft, 100, true})
}
}
// 4. 左上角 (SnapEdgeTopLeft)
distToTopLeft := math.Sqrt(math.Pow(float64(mainLeft-windowRight), 2) + math.Pow(float64(mainTop-windowBottom), 2))
if distToTopLeft <= cornerThreshold {
horizDist := math.Abs(float64(mainLeft - windowRight))
vertDist := math.Abs(float64(mainTop - windowBottom))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeTopLeft, distToTopLeft, 100, true})
}
}
// ==== 检查边缘吸附(只在没有角落吸附时检查) ====
if len(candidates) == 0 {
// 1. 吸附到主窗口右侧
distToRight := math.Abs(float64(mainRight - windowLeft))
if distToRight <= threshold {
// 计算垂直重叠
overlapTop := math.Max(float64(mainTop), float64(windowTop))
overlapBottom := math.Min(float64(mainBottom), float64(windowBottom))
verticalOverlap := math.Max(0, overlapBottom-overlapTop)
candidates = append(candidates, snapCandidate{models.SnapEdgeRight, distToRight, verticalOverlap, false})
}
// 2. 吸附到主窗口左侧
distToLeft := math.Abs(float64(mainLeft - windowRight))
if distToLeft <= threshold {
// 计算垂直重叠
overlapTop := math.Max(float64(mainTop), float64(windowTop))
overlapBottom := math.Min(float64(mainBottom), float64(windowBottom))
verticalOverlap := math.Max(0, overlapBottom-overlapTop)
candidates = append(candidates, snapCandidate{models.SnapEdgeLeft, distToLeft, verticalOverlap, false})
}
// 3. 吸附到主窗口底部
distToBottom := math.Abs(float64(mainBottom - windowTop))
if distToBottom <= threshold {
// 计算水平重叠
overlapLeft := math.Max(float64(mainLeft), float64(windowLeft))
overlapRight := math.Min(float64(mainRight), float64(windowRight))
horizontalOverlap := math.Max(0, overlapRight-overlapLeft)
candidates = append(candidates, snapCandidate{models.SnapEdgeBottom, distToBottom, horizontalOverlap, false})
}
// 4. 吸附到主窗口顶部
distToTop := math.Abs(float64(mainTop - windowBottom))
if distToTop <= threshold {
// 计算水平重叠
overlapLeft := math.Max(float64(mainLeft), float64(windowLeft))
overlapRight := math.Min(float64(mainRight), float64(windowRight))
horizontalOverlap := math.Max(0, overlapRight-overlapLeft)
candidates = append(candidates, snapCandidate{models.SnapEdgeTop, distToTop, horizontalOverlap, false})
}
}
// 如果没有候选,不吸附
if len(candidates) == 0 {
if bestSnap == nil {
return false, models.SnapEdgeNone
}
// 选择最佳吸附位置:角落吸附优先,其次考虑重叠度,最后考虑距离
bestCandidate := candidates[0]
for _, candidate := range candidates[1:] {
// 角落吸附优先级最高
if candidate.isCorner && !bestCandidate.isCorner {
bestCandidate = candidate
} else if bestCandidate.isCorner && !candidate.isCorner {
// 继续使用当前的角落吸附
continue
} else {
// 同类型的吸附,比较重叠度和距离
if math.Abs(candidate.overlap-bestCandidate.overlap) < 10 {
if candidate.distance < bestCandidate.distance {
bestCandidate = candidate
}
} else if candidate.overlap > bestCandidate.overlap {
// 重叠度更高的优先
bestCandidate = candidate
}
}
return true, bestSnap.edge
}
return true, bestCandidate.edge
}
// calculateSnapPosition 计算吸附目标位置
func (wss *WindowSnapService) calculateSnapPosition(snapEdge models.SnapEdge, currentPos models.WindowPosition, window *application.WebviewWindow) models.WindowPosition {
// 使用缓存的主窗口信息
mainPos := wss.lastMainWindowPos
mainWidth := wss.lastMainWindowSize[0]
mainHeight := wss.lastMainWindowSize[1]
// snapWindowToMainWindow 将窗口精确吸附到主窗口边缘(支持角落吸附)
func (wss *WindowSnapService) snapWindowToMainWindow(windowInfo *SnapWindowInfo, snapEdge models.SnapEdge) {
// 获取主窗口位置和尺寸
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
mainWidth, mainHeight := wss.mainWindow.Size()
// 获取子窗口位置和尺寸
windowPos, _ := wss.getWindowPosition(windowInfo.Window)
windowWidth, windowHeight := windowInfo.Window.Size()
// 计算目标位置
var targetX, targetY int
// 获取子窗口尺寸
windowWidth, windowHeight := window.Size()
switch snapEdge {
case models.SnapEdgeRight:
// 吸附到主窗口右侧
targetX = mainPos.X + mainWidth
targetY = windowPos.Y // 保持当前 Y 位置
// 如果超出主窗口范围,调整到边界
if targetY < mainPos.Y {
targetY = mainPos.Y
} else if targetY+windowHeight > mainPos.Y+mainHeight {
targetY = mainPos.Y + mainHeight - windowHeight
return models.WindowPosition{
X: mainPos.X + mainWidth,
Y: currentPos.Y, // 保持当前Y位置
}
case models.SnapEdgeLeft:
// 吸附到主窗口左侧
targetX = mainPos.X - windowWidth
targetY = windowPos.Y // 保持当前 Y 位置
// 如果超出主窗口范围,调整到边界
if targetY < mainPos.Y {
targetY = mainPos.Y
} else if targetY+windowHeight > mainPos.Y+mainHeight {
targetY = mainPos.Y + mainHeight - windowHeight
return models.WindowPosition{
X: mainPos.X - windowWidth,
Y: currentPos.Y,
}
case models.SnapEdgeBottom:
// 吸附到主窗口底部
targetX = windowPos.X // 保持当前 X 位置
targetY = mainPos.Y + mainHeight
// 如果超出主窗口范围,调整到边界
if targetX < mainPos.X {
targetX = mainPos.X
} else if targetX+windowWidth > mainPos.X+mainWidth {
targetX = mainPos.X + mainWidth - windowWidth
return models.WindowPosition{
X: currentPos.X,
Y: mainPos.Y + mainHeight,
}
case models.SnapEdgeTop:
// 吸附到主窗口顶部
targetX = windowPos.X // 保持当前 X 位置
targetY = mainPos.Y - windowHeight
// 如果超出主窗口范围,调整到边界
if targetX < mainPos.X {
targetX = mainPos.X
} else if targetX+windowWidth > mainPos.X+mainWidth {
targetX = mainPos.X + mainWidth - windowWidth
return models.WindowPosition{
X: currentPos.X,
Y: mainPos.Y - windowHeight,
}
// ==== 角落吸附 ====
case models.SnapEdgeTopRight:
// 吸附到右上角
targetX = mainPos.X + mainWidth
targetY = mainPos.Y - windowHeight
return models.WindowPosition{
X: mainPos.X + mainWidth,
Y: mainPos.Y - windowHeight,
}
case models.SnapEdgeBottomRight:
// 吸附到右下角
targetX = mainPos.X + mainWidth
targetY = mainPos.Y + mainHeight
return models.WindowPosition{
X: mainPos.X + mainWidth,
Y: mainPos.Y + mainHeight,
}
case models.SnapEdgeBottomLeft:
// 吸附到左下角
targetX = mainPos.X - windowWidth
targetY = mainPos.Y + mainHeight
return models.WindowPosition{
X: mainPos.X - windowWidth,
Y: mainPos.Y + mainHeight,
}
case models.SnapEdgeTopLeft:
// 吸附到左上角
targetX = mainPos.X - windowWidth
targetY = mainPos.Y - windowHeight
default:
// 不应该到达这里
return
return models.WindowPosition{
X: mainPos.X - windowWidth,
Y: mainPos.Y - windowHeight,
}
}
// 直接移动到目标位置,不使用平滑过渡以产生明显的吸附效果
windowInfo.Window.SetPosition(targetX, targetY)
// 更新窗口信息
windowInfo.SnapEdge = snapEdge
windowInfo.LastPos = models.WindowPosition{X: targetX, Y: targetY}
return currentPos
}
// Cleanup 清理资源
func (wss *WindowSnapService) Cleanup() {
// 如果有取消函数调用它来停止所有goroutine
if wss.cancel != nil {
wss.cancel()
}
wss.mu.Lock()
defer wss.mu.Unlock()
// 停止定时器
if wss.snapTicker != nil {
wss.snapTicker.Stop()
wss.snapTicker = nil
}
// 清空管理的窗口
wss.managedWindows = make(map[int64]*models.WindowInfo)
wss.windowRefs = make(map[int64]*application.WebviewWindow)
}
// ServiceShutdown 实现服务关闭接口

View File

@@ -12,13 +12,13 @@ func SetupSystemTray(app *application.App, mainWindow *application.WebviewWindow
// 创建系统托盘
systray := app.SystemTray.New()
// 设置标签
systray.SetLabel("voidraft")
systray.Label()
// 设置图标
iconBytes, _ := assets.ReadFile("appicon.png")
systray.SetIcon(iconBytes)
// 设置标签
systray.SetLabel("VoidRaft")
// 创建托盘菜单
menu := app.NewMenu()

View File

@@ -0,0 +1,4 @@
package version
// Version 版本注入 Ldflags
var Version = "0.0.0"

73
scripts/version.bat Normal file
View File

@@ -0,0 +1,73 @@
@echo off
setlocal enabledelayedexpansion
REM Simplified version management script - Windows version
REM Auto-increment patch version from git tags or use custom version
REM Configuration section - Set custom version here if needed
set "CUSTOM_VERSION="
REM Example: set "CUSTOM_VERSION=2.0.0"
set "VERSION_FILE=version.txt"
REM Check if custom version is set
if not "%CUSTOM_VERSION%"=="" (
echo [INFO] Using custom version: %CUSTOM_VERSION%
set "VERSION=%CUSTOM_VERSION%"
goto :save_version
)
REM Check if git is available
git --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] Git is not installed or not in PATH
exit /b 1
)
REM Check if in git repository
git rev-parse --git-dir >nul 2>&1
if errorlevel 1 (
echo [ERROR] Not in a git repository
exit /b 1
)
REM Get latest git tag
git describe --abbrev=0 --tags > temp_tag.txt 2>nul
if errorlevel 1 (
echo [ERROR] No git tags found in repository
if exist temp_tag.txt del temp_tag.txt
exit /b 1
)
set /p LATEST_TAG=<temp_tag.txt
del temp_tag.txt
if not defined LATEST_TAG (
echo [ERROR] Failed to read git tag
exit /b 1
)
echo [INFO] Latest git tag: %LATEST_TAG%
REM Remove v prefix
set "CLEAN_VERSION=%LATEST_TAG:v=%"
REM Split version number and increment patch
for /f "tokens=1,2,3 delims=." %%a in ("%CLEAN_VERSION%") do (
set "MAJOR=%%a"
set "MINOR=%%b"
set /a "PATCH=%%c+1"
)
set "VERSION=%MAJOR%.%MINOR%.%PATCH%"
echo [INFO] Auto-incremented patch version: %VERSION%
:save_version
REM Output version information
echo [SUCCESS] Version resolved: %VERSION%
echo VERSION=%VERSION%
REM Save to file
echo VERSION=%VERSION% > %VERSION_FILE%
echo [INFO] Version information saved to %VERSION_FILE%

50
scripts/version.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# 配置区域 - 如需自定义版本,请在此处设置
CUSTOM_VERSION=""
# 示例: CUSTOM_VERSION="2.0.0"
VERSION_FILE="version.txt"
# 检查是否设置了自定义版本
if [ -n "$CUSTOM_VERSION" ]; then
echo "[INFO] Using custom version: $CUSTOM_VERSION"
VERSION="$CUSTOM_VERSION"
else
# 检查git是否可用
if ! command -v git &> /dev/null; then
echo "[ERROR] Git is not installed or not in PATH"
exit 1
elif ! git rev-parse --git-dir &> /dev/null; then
echo "[ERROR] Not in a git repository"
exit 1
else
# 获取最新的git标签
LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null)
if [ -z "$LATEST_TAG" ]; then
echo "[ERROR] No git tags found in repository"
exit 1
else
echo "[INFO] Latest git tag: $LATEST_TAG"
# 移除v前缀
CLEAN_VERSION=${LATEST_TAG#v}
# 分割版本号并递增patch版本
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN_VERSION"
PATCH=$((PATCH + 1))
VERSION="$MAJOR.$MINOR.$PATCH"
echo "[INFO] Auto-incremented patch version: $VERSION"
fi
fi
fi
# 输出版本信息
echo "VERSION=$VERSION"
# 保存到文件供其他脚本使用
echo "VERSION=$VERSION" > "$VERSION_FILE"
echo "[INFO] Version information saved to $VERSION_FILE"

1
version.txt Normal file
View File

@@ -0,0 +1 @@
VERSION=1.3.5