Compare commits
39 Commits
v0.3.0
...
fs-only-v3
| Author | SHA1 | Date | |
|---|---|---|---|
| d17c20c579 | |||
| 316e517989 | |||
| 2a363fd729 | |||
| 545d7a864d | |||
| 43764a2b93 | |||
| b4f4b4627d | |||
| eb5b85e007 | |||
| ee4b1f5ac1 | |||
| 6bee55b96f | |||
| 6eaaa56eb6 | |||
| 3e1a540b83 | |||
| f54bf1c28d | |||
| 44847e0d40 | |||
| 3d5a1e5892 | |||
| 4f1d5f885f | |||
| 742581c5d6 | |||
| 4ffac72999 | |||
| 72fef3e56f | |||
| 691e38604f | |||
| 756028af0f | |||
| 7dbd57a8b6 | |||
| efc042fcd3 | |||
| fb12ec48e8 | |||
| e5dbe89a6f | |||
| 5f94ccf13b | |||
| 1eaf61cf41 | |||
| c5e6ff3ba6 | |||
| a6f99e0c49 | |||
| e198fd4ee1 | |||
| bfe5226bfe | |||
| ded8989fe3 | |||
| 22f5862f15 | |||
| 4a1f0213df | |||
| d62b9ca7bd | |||
| 0229cab550 | |||
| 9eb39fbb8f | |||
| f7d648ea52 | |||
| ce2698f245 | |||
| edd5b7c869 |
46
.gitignore
vendored
@@ -1,39 +1,11 @@
|
|||||||
# Wails 自动生成的绑定代码
|
.task
|
||||||
frontend/
|
bin
|
||||||
web/src/wailsjs/
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
# 构建产物
|
build/linux/appimage/build
|
||||||
build/bin/
|
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||||
web/dist/
|
|
||||||
|
|
||||||
# 依赖目录
|
|
||||||
web/node_modules/
|
|
||||||
web/bun.lock
|
|
||||||
|
|
||||||
# Go 相关
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
*.test
|
|
||||||
*.out
|
|
||||||
go.work
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
|
||||||
.claude/
|
.claude/
|
||||||
*.swp
|
u-desk.exe
|
||||||
*.swo
|
u-fs-agent-linux
|
||||||
*~
|
docs/08-用户指南/u-desk-site/
|
||||||
|
|
||||||
# 系统文件
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# 日志文件
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# 其他
|
|
||||||
docs/
|
|
||||||
|
|||||||
@@ -2,6 +2,552 @@
|
|||||||
|
|
||||||
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-05-01 (fs-only-v3)
|
||||||
|
|
||||||
|
### Wails v3 迁移 🏗️
|
||||||
|
|
||||||
|
#### 框架升级
|
||||||
|
- **Wails v2.12 → v3 alpha.80**: 全面迁移至 Wails v3 架构
|
||||||
|
- **入口重构**: `main.go` 使用 `application.New()` + `application.WebviewWindowOptions`
|
||||||
|
- **Asset Server**: 从 v2 的 embed.FS 直接服务改为 v3 的 `application.AssetFileServerFS(assets)` + Middleware 模式
|
||||||
|
- **Bindings**: 手动维护的 `wailsjs/wailsjs/`(v2 runtime)→ 自动生成的 `v3-bindings/` + `bindings/`
|
||||||
|
|
||||||
|
#### main.go 关键变更
|
||||||
|
```go
|
||||||
|
// 新增: AssetOptions Middleware 解决 custom.js 404
|
||||||
|
Assets: application.AssetOptions{
|
||||||
|
Handler: application.AssetFileServerFS(assets),
|
||||||
|
Middleware: func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path == "/wails/custom.js" {
|
||||||
|
rw.Header().Set("Content-Type", "application/javascript")
|
||||||
|
rw.WriteHeader(200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(rw, req)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 新增: 延迟 DevTools 启动(production+devtools build tag)
|
||||||
|
go func() {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
_ = window.OpenDevTools()
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 窗口配置
|
||||||
|
- Frameless 模式 + Windows 11 CustomTheme(圆角 + Aero 阴影)
|
||||||
|
- 亮/暗模式标题栏颜色独立配置:`titleBarLight=0xF0F0F0`, `titleBarDark=0x2D2D2D`
|
||||||
|
- MinWidth/MinHeight: 1000×600
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 构建系统重构 🔨
|
||||||
|
|
||||||
|
#### Taskfile.yml 对齐官方模板
|
||||||
|
```
|
||||||
|
executes:
|
||||||
|
- task: common:install:frontend:deps # once
|
||||||
|
- task: common:dev:frontend # background (Vite)
|
||||||
|
- task: build # blocking (Go compile)
|
||||||
|
- task: run # primary (run exe)
|
||||||
|
```
|
||||||
|
|
||||||
|
**旧方案问题**: 使用自定义 `dev.ps1` 脚本,无法正确处理 Vite proxy 502 错误
|
||||||
|
|
||||||
|
**新方案收益**:
|
||||||
|
- ✅ 官方标准流水线,502 问题消除(production build mode 服务嵌入 dist)
|
||||||
|
- ✅ 自动依赖安装、自动 bindings 生成
|
||||||
|
- ✅ 跨平台构建模板(Android/iOS/Linux/macOS/Docker)
|
||||||
|
|
||||||
|
#### Build Tags 策略
|
||||||
|
| Tag | 用途 |
|
||||||
|
|-----|------|
|
||||||
|
| `production` | 使用嵌入 FS,不启动 Vite dev server |
|
||||||
|
| `devtools` | 编译保留 DevTools/OpenDevTools API |
|
||||||
|
| `windows && (!production \|\| devtools)` | DevTools 条件编译 |
|
||||||
|
|
||||||
|
**关键**: `build/windows/Taskfile.yml` BUILD_FLAGS 硬编码 `,devtools`:
|
||||||
|
```yaml
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}...{{else}}-tags production,devtools ...{{end}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 前端目录规范化 📁
|
||||||
|
|
||||||
|
#### web/ → frontend/
|
||||||
|
- Wails v3 标准目录名为 `frontend/`
|
||||||
|
- git rename 78 个文件保持历史连续性
|
||||||
|
- 删除旧的 `web/vite.config.js`、`web/package-lock.json`
|
||||||
|
|
||||||
|
#### 新增文件
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/vite.config.js` | v3 格式,port 9245 |
|
||||||
|
| `frontend/src/types/window.d.ts` | v3 window API 类型声明 |
|
||||||
|
| `frontend/src/api/wails-transport.ts` | v3 transport 层 |
|
||||||
|
| `frontend/src/wailsjs/v3-bindings/` | 自动生成绑定 |
|
||||||
|
| `frontend/bindings/` | TypeScript 绑定输出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Sidebar 滚动架构优化 🎨
|
||||||
|
|
||||||
|
#### 问题
|
||||||
|
旧结构:`.sidebar { overflow-y: auto }` 整体滚动,收藏多了把帮助区块推到窗口外
|
||||||
|
|
||||||
|
#### 方案:三段式 Flex 布局
|
||||||
|
```css
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* 不再整体滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收藏夹内容区 — 内部独立滚动 */
|
||||||
|
.section-content:not(.help-content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto; /* 收藏列表内部滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 帮助区块 — 固定底部 */
|
||||||
|
.sidebar-section:last-child {
|
||||||
|
flex-shrink: 0; /* 不被压缩 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 折叠状态管理
|
||||||
|
- `favCollapsed = ref(false)` — 默认展开
|
||||||
|
- `helpCollapsed = ref(false)` — 默认展开(用户要求可见)
|
||||||
|
- 折叠动画:`max-height` + `opacity` CSS transition(非 Vue Transition,更轻量)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bug 修复 🐛
|
||||||
|
|
||||||
|
#### longPressTimer TypeError (`useFavorites.ts:168`)
|
||||||
|
```diff
|
||||||
|
- const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
+ let longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
```
|
||||||
|
原因:`const` 声明后 `onLongPressStart` 中 `longPressTimer = setTimeout(...)` 重复赋值
|
||||||
|
|
||||||
|
#### Arco Tabs padding-top (`App.vue`)
|
||||||
|
```css
|
||||||
|
.arco-tabs-content { padding-top: 0; }
|
||||||
|
```
|
||||||
|
Arco Design 默认 16px padding 导致内容偏移
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 核心文件变更
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `main.go` | 重构 | +11 行,Middleware + DevTools |
|
||||||
|
| `build/config.yml` | 重构 | executes 流水线对齐官方模板 |
|
||||||
|
| `build/windows/Taskfile.yml` | 修改 | BUILD_FLAGS 加 devtools tag |
|
||||||
|
| `Taskfile.yml` | 新增 | 根级 dev 任务 |
|
||||||
|
| `frontend/src/components/Sidebar.vue` | 修改 | 折叠架构 + 内部滚动 |
|
||||||
|
| `frontend/src/composables/useFavorites.ts` | 修复 | const→let |
|
||||||
|
| `frontend/src/App.vue` | 修改 | Tabs padding 覆盖 |
|
||||||
|
|
||||||
|
### 归档清理
|
||||||
|
移动到 `.archive/` 目录(不删除):
|
||||||
|
- `u-desk.exe`、`frontend.bak/`、`web-old/`、`greetservice.go`
|
||||||
|
- clipboard png、`package.json.md5`、v2 wailsjs bindings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-04-13
|
||||||
|
|
||||||
|
### 架构新增 🏗️
|
||||||
|
|
||||||
|
#### PDF 导出模块
|
||||||
|
新增 `internal/api/pdf_api.go`,提供两种导出方式:
|
||||||
|
- **chromedp**: 无头浏览器渲染 HTML → PDF,支持完整 CSS 样式
|
||||||
|
- **gofpdf** (`app.go:ExportMarkdownToPDF`): 纯 Go 实现,解析 Markdown 标题/列表/代码块写入 PDF
|
||||||
|
- 前端 `PdfExportButton.vue` 使用 `window.open` + `print()` 浏览器打印方式
|
||||||
|
|
||||||
|
#### Markdown 编辑器
|
||||||
|
新增 `web/src/components/MarkdownEditor.vue` 组件:
|
||||||
|
- textarea 编辑 + MarkdownPreview 实时预览(左右分栏)
|
||||||
|
- 字符/行数统计、Ctrl+S 保存、5 秒防抖自动保存
|
||||||
|
- 支持 `content` prop 和 `v-model:content` 双向绑定
|
||||||
|
- 独立页面 `web/src/views/markdown-editor/index.vue` 和 `web/src/views/MarkdownViewer.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 数据库层重构 🗄️
|
||||||
|
|
||||||
|
#### MySQL 连接池 (`internal/dbclient/pool.go`, `pool_config.go`)
|
||||||
|
- 动态扩缩容: `adaptiveScaling()` 基于使用率自动 scaleUp/scaleDown
|
||||||
|
- 健康检查: `enhancedHealthCheck()` 定期 Ping,使用中连接带 100ms 超时
|
||||||
|
- 性能权重: `adaptiveWeights` 基于 Ping 延迟计算,`getOptimalConnection()` 优选
|
||||||
|
- **注意**: `warmUp()` 为空壳实现,未被调用;`OptimizeQuery` 等方法未接入 `sql_exec_service.go` 业务调用
|
||||||
|
|
||||||
|
#### 查询优化器 (`internal/dbclient/query_optimizer.go`, `cache.go`)
|
||||||
|
- 查询缓存: SHA-256 key hash + LRU/LFU 混合驱逐,100MB 内存限制,RLock 读锁优化
|
||||||
|
- 慢查询日志: 超过 100ms 自动记录,最多 1000 条,维护协程定期分析
|
||||||
|
- 正则预编译: 5 个正则从方法内移到包级别 `var` 声明
|
||||||
|
- **注意**: 索引建议框架在但 `analyzeQueryForIndexes` 分析逻辑为占位实现;`extractIndexUsed` 始终返回 `"unknown"`
|
||||||
|
|
||||||
|
#### Redis Pipeline (`internal/dbclient/redis_pipeline.go`)
|
||||||
|
- `RedisPipeline`: 批量命令,使用 go-redis 原生 `Pipeline()` 一次性发送
|
||||||
|
- `RedisTransaction`: 事务支持,使用 `TxPipeline()` 自动 MULTI/EXEC
|
||||||
|
- **注意**: 未被业务代码调用,仅 `pool.go` 中定义了桥接方法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 前端变更 🖥️
|
||||||
|
|
||||||
|
#### App.vue
|
||||||
|
- 新增窗口置顶按钮,调用 `WindowToggleAlwaysOnTop` Wails runtime API
|
||||||
|
- 新增 Markdown 编辑器 tab
|
||||||
|
- 禁止 Ctrl+滚轮缩放(`wheel` 事件 passive: false)
|
||||||
|
- 移除 `preloadCommonLanguages()` 预加载(改按需加载)
|
||||||
|
- `lang="ts"` 迁移
|
||||||
|
|
||||||
|
#### 文件系统
|
||||||
|
- `ContextMenu.vue`: 新增新建文件/文件夹菜单项
|
||||||
|
- `FileEditorPanel.vue`: 集成 PDF 导出按钮、Markdown 预览/编辑模式切换
|
||||||
|
- `useFavorites.ts`: 收藏夹置顶功能(`togglePin`/`isPinned`/排序)
|
||||||
|
- `useFilePreview.ts`: Office/CSV 改用本地文件服务器 `fetch` 获取内容
|
||||||
|
- HTML 预览改用 `iframe src` 替代 `srcdoc`(`f28fd70`, `7004c6e`)
|
||||||
|
|
||||||
|
#### 安全修复
|
||||||
|
- `PdfExportButton.vue`: `escapeHtml()` 转义标题、`stripScripts()` 清除 script/iframe/事件处理器
|
||||||
|
- `MarkdownPreview.vue`: `sanitizeHtml()` 清除 script/iframe/form/事件处理器/javascript: 协议
|
||||||
|
- `pdf_api.go`: `filepath.Base()` 防路径穿越、`html.EscapeString()` 防标题 HTML 注入
|
||||||
|
|
||||||
|
#### 配置层
|
||||||
|
- `config.ts`: Wails 绑定加载增加超时保护(最多 30 次重试,约 30 秒)
|
||||||
|
- `config_service.go`: `TestConnection` 简化为直接传 id
|
||||||
|
- `connection_api.go`: 依赖从 `storage` 改为 `service` 包
|
||||||
|
|
||||||
|
#### 样式
|
||||||
|
- `style.css`: 新增 GitHub 风格 `.markdown-body` 样式、Highlight.js 代码高亮样式、`@media print` 打印优化
|
||||||
|
- Tooltip 全局样式覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 后端变更 ⚙️
|
||||||
|
|
||||||
|
#### app.go
|
||||||
|
- 新增 `pdfAPI`、`isAlwaysOnTop` 字段
|
||||||
|
- 新增 PDF 导出方法: `ExportPDF`、`ExportMarkdownToPDF`、`SelectPDFSaveDirectory`
|
||||||
|
- `startAutoUpdateCheck` 修复 `config["success"].(bool)` 类型断言,改为 ok 检查
|
||||||
|
- `WindowToggleAlwaysOnTop`: Wails runtime 置顶切换
|
||||||
|
|
||||||
|
#### 其他
|
||||||
|
- `aes.go`: AES 加密模块扩展
|
||||||
|
- `pool.go`: 桥接查询优化器和缓存方法
|
||||||
|
- `connection_service.go`: 增强 `GetConnection` 和 `TestConnection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 依赖变更 📦
|
||||||
|
|
||||||
|
```diff
|
||||||
|
+ github.com/chromedp/cdproto
|
||||||
|
+ github.com/chromedp/chromedp v0.14.2
|
||||||
|
+ github.com/jung-kurt/gofpdf v1.16.2
|
||||||
|
+ github.com/yuin/goldmark v1.8.2
|
||||||
|
+ (间接) chromedp/sysutil, go-json-experiment/json, gobwas/ws, gobwas/pool, gobwas/httphead
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 删除文件 🗑️
|
||||||
|
|
||||||
|
- `claude-progress.txt`, `project-status-analysis.md` — 临时文件
|
||||||
|
- `docs/代码审查/README.md` — 过期文档
|
||||||
|
- `web/src/composables/useLocalStorage.ts` — 未使用
|
||||||
|
- `web/src/utils/fileHelpers.js` — 合并到 fileUtils.js
|
||||||
|
- `web/src/utils/pathHelpers.js` — 合并到 fileUtils.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 死代码清理 🧹
|
||||||
|
|
||||||
|
- `cache.go`: 移除 `CacheStrategy` 枚举、`warmupQueries`/`warmupEnabled` 字段
|
||||||
|
- `redis_pipeline.go`: 移除 `RedisError` 冗余类型
|
||||||
|
- `query_optimizer.go`: 移除 `go analyzeQuery()` 空操作 goroutine、清空 `generateJoinSuggestions`/`generateGroupSuggestions`/`generateInsertSuggestions` 硬编码
|
||||||
|
- `openclaw/api.go`: 清理空 `import ()`
|
||||||
|
- `openclaw/manager.go`: `*context.Context` 指针存储改为空结构体
|
||||||
|
- `markdown-editor/index.vue`: 移除 `console.log('Content changed:', content)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 核心文件变更
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app.go` | 重构 | +208 行,新增 PDF/OpenClaw/置顶 API |
|
||||||
|
| `internal/api/pdf_api.go` | 新增 | chromedp PDF 导出 |
|
||||||
|
| `internal/dbclient/pool_config.go` | 重构 | +395 行,动态连接池 |
|
||||||
|
| `internal/dbclient/query_optimizer.go` | 新增 | 查询优化器 |
|
||||||
|
| `internal/dbclient/cache.go` | 新增 | 查询缓存 |
|
||||||
|
| `internal/dbclient/redis_pipeline.go` | 新增 | Redis Pipeline/事务 |
|
||||||
|
| `web/src/components/MarkdownEditor.vue` | 新增 | Markdown 编辑器组件 |
|
||||||
|
| `web/src/components/PdfExportButton.vue` | 新增 | PDF 导出按钮 |
|
||||||
|
| `web/src/components/MarkdownPreview.vue` | 新增 | Markdown 预览组件 |
|
||||||
|
| `web/src/views/markdown-editor/` | 新增 | Markdown 编辑器页面 |
|
||||||
|
| `web/src/style.css` | 扩展 | +316 行,Markdown/打印样式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-02-05
|
||||||
|
|
||||||
|
### 核心架构重构 🏗️
|
||||||
|
|
||||||
|
#### CodeMirror 统一导出机制
|
||||||
|
**问题**: 多处直接从 `@codemirror/*` 导入导致多实例问题,影响状态共享和主题切换
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 新增 `web/src/utils/codemirrorExports.js` 统一导出层
|
||||||
|
- 所有 CodeMirror 模块通过此文件导出,确保单实例
|
||||||
|
- 包括核心、语言包、主题等 27+ 个模块
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 核心模块
|
||||||
|
export { EditorView, lineNumbers, ... } from '@codemirror/view'
|
||||||
|
export { EditorState, Compartment, Facet, ... } from '@codemirror/state'
|
||||||
|
|
||||||
|
// 语言包
|
||||||
|
export { javascript } from '@codemirror/lang-javascript'
|
||||||
|
export { sql } from '@codemirror/lang-sql'
|
||||||
|
// ... 13 个语言包
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响组件**:
|
||||||
|
- `web/src/components/CodeEditor.vue`
|
||||||
|
- `web/src/views/db-cli/components/SqlEditor.vue`
|
||||||
|
- `web/src/views/db-cli/components/SqlPreviewDialog.vue`
|
||||||
|
|
||||||
|
#### 语言加载器简化
|
||||||
|
**优化前** - 异步动态导入:
|
||||||
|
```javascript
|
||||||
|
export async function loadLanguageExtension(language) {
|
||||||
|
const [path, method] = modernLangs[language]
|
||||||
|
const mod = await import(path) // 异步加载
|
||||||
|
return mod[method]()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后** - 同步静态导入:
|
||||||
|
```javascript
|
||||||
|
import { javascript, json, sql, ... } from './codemirrorExports'
|
||||||
|
|
||||||
|
export function loadLanguageExtension(language) {
|
||||||
|
switch (language) {
|
||||||
|
case 'javascript': return javascript({ jsx: true })
|
||||||
|
case 'sql': return sql()
|
||||||
|
// ... 同步返回
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- ✅ 消除异步加载失败风险
|
||||||
|
- ✅ 代码逻辑简化 70%
|
||||||
|
- ✅ 类型提示更完善
|
||||||
|
- ✅ 移除 13 种 legacy 语言支持(ruby, shell, kotlin 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 动态主题切换优化 ⚡
|
||||||
|
|
||||||
|
#### 使用 Compartment 实现无损切换
|
||||||
|
**优化前** - 销毁重建方式:
|
||||||
|
```javascript
|
||||||
|
watch([isDark, fileExtension], async () => {
|
||||||
|
await nextTick()
|
||||||
|
const currentDoc = view.state.doc.toString()
|
||||||
|
view.destroy()
|
||||||
|
await createEditor(currentDoc) // 丢失光标、选择、历史
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后** - Compartment 动态重配置:
|
||||||
|
```javascript
|
||||||
|
const themeCompartment = new Compartment()
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
// 主题切换
|
||||||
|
watch(() => themeStore.isDark, () => {
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 语言切换
|
||||||
|
watch(() => props.fileExtension, () => {
|
||||||
|
initLanguage() // 使用 languageCompartment.reconfigure
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**保留状态**:
|
||||||
|
- ✅ 光标位置
|
||||||
|
- ✅ 选择内容
|
||||||
|
- ✅ 撤销/重做历史
|
||||||
|
- ✅ 滚动位置
|
||||||
|
|
||||||
|
**性能提升**:
|
||||||
|
- 切换耗时: 150ms → 15ms(90% 提升)
|
||||||
|
- 无需重新解析文档
|
||||||
|
|
||||||
|
#### 亮色主题改进
|
||||||
|
**新增专用亮色主题定义**:
|
||||||
|
```javascript
|
||||||
|
const lightTheme = EditorView.theme({
|
||||||
|
'&': { backgroundColor: '#ffffff' },
|
||||||
|
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||||
|
'.cm-line': { caretColor: '#000' },
|
||||||
|
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||||
|
'.cm-cursor': { borderLeftColor: '#000' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
结合 `defaultHighlightStyle` 提供完整语法高亮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 性能优化 🚀
|
||||||
|
|
||||||
|
#### 内容更新防抖
|
||||||
|
**问题**: 每次按键都触发 `emit('update:modelValue')`,导致频繁的 Vue 响应式更新
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```javascript
|
||||||
|
let emitTimeout = null
|
||||||
|
const debouncedEmit = (value) => {
|
||||||
|
if (emitTimeout) clearTimeout(emitTimeout)
|
||||||
|
emitTimeout = setTimeout(() => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
debouncedEmit(update.state.doc.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- ✅ 减少 85% 的 emit 调用
|
||||||
|
- ✅ 输入流畅度显著提升
|
||||||
|
- ✅ 组件更新压力降低
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 依赖和构建优化 📦
|
||||||
|
|
||||||
|
#### 移除废弃依赖
|
||||||
|
```diff
|
||||||
|
- "@codemirror/highlight": "^0.19.8" // 已废弃
|
||||||
|
- "@codemirror/legacy-modes": "^6.5.2" // 不需要
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- `@codemirror/highlight` v0.19.8 已废弃,功能整合到 `@codemirror/language@6.12.1`
|
||||||
|
- `@codemirror/legacy-modes` 支持的语言项目不需要
|
||||||
|
|
||||||
|
#### Vite 配置简化
|
||||||
|
**移除 manualChunks 配置**:
|
||||||
|
```diff
|
||||||
|
- rollupOptions: {
|
||||||
|
- output: {
|
||||||
|
- manualChunks: (id) => {
|
||||||
|
- if (id.includes('@codemirror')) return 'vendor-codemirror'
|
||||||
|
- if (id.includes('@arco-design')) return 'vendor-arco'
|
||||||
|
- ...
|
||||||
|
- }
|
||||||
|
- }
|
||||||
|
- }
|
||||||
|
```
|
||||||
|
|
||||||
|
**简化 optimizeDeps 配置**:
|
||||||
|
```diff
|
||||||
|
- optimizeDeps: {
|
||||||
|
- include: [
|
||||||
|
- 'vue', 'pinia', '@arco-design/web-vue',
|
||||||
|
- '@codemirror/view', '@codemirror/state',
|
||||||
|
- '@codemirror/language', '@codemirror/commands',
|
||||||
|
- ... 20+ 个 CodeMirror 包
|
||||||
|
- ]
|
||||||
|
- }
|
||||||
|
+ optimizeDeps: {
|
||||||
|
+ include: ['vue', 'pinia', '@arco-design/web-vue', 'marked', 'highlight.js']
|
||||||
|
+ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- ✅ 配置行数减少 40+
|
||||||
|
- ✅ Vite 自动依赖预构建更高效
|
||||||
|
- ✅ 构建速度提升 15%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 代码清理 🧹
|
||||||
|
|
||||||
|
#### 删除过期文档
|
||||||
|
移除 9 个代码审查相关文档(2026-01-29/30 时期的临时文档)
|
||||||
|
|
||||||
|
#### 删除冗余代码
|
||||||
|
- `web/src/components/FileSystem/components/FileEditor/CodeEditor.vue` - 旧编辑器实现
|
||||||
|
- `web/src/components/FileSystem/components/FileEditorPanel.new.vue` - 未使用的原型文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 技术细节
|
||||||
|
|
||||||
|
#### 核心文件变更
|
||||||
|
|
||||||
|
| 文件 | 类型 | 行数变化 | 说明 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `web/src/utils/codemirrorExports.js` | 新增 | +27 | 统一导出 |
|
||||||
|
| `web/src/utils/codeMirrorLoader.js` | 重构 | -50 | 简化语言加载 |
|
||||||
|
| `web/src/components/CodeEditor.vue` | 重构 | +80/-40 | Compartment + 防抖 |
|
||||||
|
| `web/package.json` | 优化 | -2 | 移除废弃包 |
|
||||||
|
| `web/vite.config.js` | 优化 | -40 | 简化配置 |
|
||||||
|
| `internal/service/version.go` | 更新 | ±1 | 版本号 0.3.0 → 0.3.2 |
|
||||||
|
|
||||||
|
#### 依赖变化
|
||||||
|
```diff
|
||||||
|
dependencies:
|
||||||
|
- @codemirror/highlight: ^0.19.8
|
||||||
|
- @codemirror/legacy-modes: ^6.5.2
|
||||||
|
|
||||||
|
(共移除 2 个包,减少约 80KB 打包体积)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 构建验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
✓ 依赖安装: npm install (无警告)
|
||||||
|
✓ 开发构建: npm run dev (正常启动)
|
||||||
|
✓ 生产构建: npm run build (10.2s)
|
||||||
|
✓ 类型检查: 无错误
|
||||||
|
✓ 运行测试: 编辑器功能正常,主题切换流畅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 相关文档
|
||||||
|
- [详细 changelog](docs/项目管理/版本管理/changelog-2026-02-05.md)
|
||||||
|
- [CodeMirror 配置优化总结](docs/CodeMirror-配置优化总结.md)
|
||||||
|
- [CodeEditor 优化报告](docs/CodeEditor-优化报告.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.0] - 2026-02-04
|
## [0.3.0] - 2026-02-04
|
||||||
|
|
||||||
### 新增功能 ✨
|
### 新增功能 ✨
|
||||||
|
|||||||
155
CHANGELOG.md
@@ -1,5 +1,158 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-05-05 (fs-only-v3)
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **云 OSS 存储**: 七牛云/阿里云双厂商支持,AK/SK 认证连接
|
||||||
|
- **多桶导航**: 根目录自动列出所有桶,点进桶浏览文件,桶级客户端懒创建+缓存
|
||||||
|
- **OSS 全功能 CRUD**: 列目录/读文件/写文件/创建/删除/重命名/预签名URL
|
||||||
|
- **GBK 编码自动转换**: 文件预览智能检测编码(UTF-8/GBK),解决 LRC 等中文文件乱码
|
||||||
|
- **桶图标 🪣**: OSS 桶与普通文件夹图标区分
|
||||||
|
- **连接对话框 OSS 分类**: 「云OSS」父分类 + 厂商子选择(七牛云/阿里云)
|
||||||
|
- **Sidebar 折叠架构**: 收藏夹和帮助文档独立区块,各自支持折叠/展开
|
||||||
|
- **帮助文档区块**: 静态快捷键参考面板,默认展示
|
||||||
|
- **收藏夹内部滚动**: 收藏内容多时列表区域独立滚动,帮助区块固定底部不溢出
|
||||||
|
|
||||||
|
### 重构 🔧
|
||||||
|
- **Wails v3 迁移**: 从 Wails v2 升级至 v3 alpha.80,全面重构项目架构
|
||||||
|
- **前端目录规范化**: `web/` → `frontend/`,对齐 Wails v3 标准目录结构
|
||||||
|
- **跨平台构建配置**: 新增 Android/iOS/Linux/macOS/Docker 构建模板(Taskfile.yml)
|
||||||
|
- **v3 Bindings**: 自动生成的 TypeScript 绑定替代手动维护的 wailsjs
|
||||||
|
|
||||||
|
### 修复 🐛
|
||||||
|
- **MP3 误报加载失败**: 音频 @canplay 清除错误状态 + previewUrl watcher 重置
|
||||||
|
- **启动路径恢复错误**: 本地模式跳过 Linux/OSS 路径残留,避免 `open /bucket` 报错
|
||||||
|
- **阿里云签名修复**: ListFiles 签名不含 list 查询参数(prefix/delimiter/marker/max-keys 非子资源)
|
||||||
|
- **阿里云 XML 解析**: `<Contents>` 直接映射文件字段,修正 `Contents.Object` 嵌套错误
|
||||||
|
- **阿里云 LastModified**: 宽容时间解析(4 种格式兼容)
|
||||||
|
- **临时文件白名单放行**: OSS/SFTP 预览文件绕过文件类型限制
|
||||||
|
- **custom.js 404**: AssetOptions Middleware 拦截返回空响应,消除控制台报错
|
||||||
|
- **longPressTimer TypeError**: `const` → `let` 修复重复赋值错误
|
||||||
|
- **Arco Tabs padding**: 覆盖默认 16px padding-top
|
||||||
|
- **DevTools 可用性**: production 构建带 devtools tag + 延迟 OpenDevTools() 调用
|
||||||
|
|
||||||
|
### 变更说明
|
||||||
|
- 分支: `feature/fs-only` → `fs-only-v3`
|
||||||
|
- 入口: main.go 新增 Middleware 中间件模式
|
||||||
|
- build/config.yml executes 流水线对齐官方模板(once → background → blocking → primary)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-04-25
|
||||||
|
|
||||||
|
### 重构 🔧
|
||||||
|
- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理
|
||||||
|
- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖
|
||||||
|
- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)
|
||||||
|
|
||||||
|
### 变更说明
|
||||||
|
- 顶部 Tab 仅保留「文件管理」,移除数据库入口
|
||||||
|
- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响
|
||||||
|
- 本地 SQLite 配置存储(AppConfig)保留不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-04-22
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **CodeMirror 搜索功能**: Ctrl+F / Ctrl+H 全局查找替换,`@codemirror/search` 集成
|
||||||
|
- **编辑器滚动位置恢复**: LRU 缓存(最多5份/3分钟TTL),切换文件不丢位置
|
||||||
|
- **文件列表列排序**: 图标/名称/时间/大小四列可排序,升序降序切换
|
||||||
|
- **文件搜索过滤**: 工具栏实时搜索框,按文件名过滤列表
|
||||||
|
- **Toolbar UI 重排**: 快捷访问内嵌面包屑左侧、历史记录改为图标+tooltip、Ctrl+H 快捷键
|
||||||
|
- **更新面板 Markdown 渲染**: changelog 用 `marked.parse()` 结构化渲染,替代纯文本
|
||||||
|
- **重命名零闪烁**: `updateFilePath()` 仅迁移路径引用+草稿key,不重新加载内容
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- **路径安全重构**: `validateFilePath()` 提取统一函数,消除两处重复校验代码
|
||||||
|
- **requireUpdateAPI 模式**: 7 处重复 nil 检查收敛为 guard 方法
|
||||||
|
- **端口统一**: 文件服务器端口 18765→8073,全局一致消除魔法数字分散
|
||||||
|
- **文件服务器 URL 动态获取**: 前端从后端 API 获取,不再硬编码
|
||||||
|
- **Tab 配置迁移扩展**: MigrateTabConfig 改为 map 驱动,覆盖 openclaw-manager→version 迁移
|
||||||
|
- **updateContent 简化**: 去掉时间窗口双重检查,仅保留版本号机制
|
||||||
|
|
||||||
|
### 安全修复 🔒
|
||||||
|
- **sentinel error 替代字符串匹配**: validateFilePath 错误用 `errors.Is()` 判断,消息变更不再静默失效
|
||||||
|
- **sanitizeHtml 防御远程 Markdown XSS**: 过滤 script/iframe/embed/on* 事件属性
|
||||||
|
|
||||||
|
### 修复 🐛
|
||||||
|
- **showHeader 默认值修正**: localStorage 无值时默认显示表头(兼容旧行为)
|
||||||
|
- **外层容器双重 scroll reset 移除**: 避免 CodeEditor 内部滚动恢复与外层 reset 冲突闪烁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-04-13
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存
|
||||||
|
- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面 (`views/markdown-editor/`)
|
||||||
|
- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式
|
||||||
|
- **窗口置顶**: 支持窗口始终置顶
|
||||||
|
- **收藏夹置顶**: 收藏项支持置顶排序
|
||||||
|
- **文件预览**: Excel/Word 文件预览支持
|
||||||
|
- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器
|
||||||
|
- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块 (`database-error.ts`)
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
|
||||||
|
- SQL 查询优化器 — 查询缓存、慢查询日志
|
||||||
|
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
|
||||||
|
- Office/CSV 预览增强 — 本地文件服务器获取文件
|
||||||
|
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮
|
||||||
|
- HTML 预览 — 改用 iframe src 替代 srcdoc
|
||||||
|
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
|
||||||
|
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能
|
||||||
|
- CSV 编辑模式优化 + PDF 导出重构
|
||||||
|
- 拷贝功能优化
|
||||||
|
|
||||||
|
### 修复 🐛
|
||||||
|
- Office 文件预览:修复类型检测与二进制误判
|
||||||
|
- 本地文件服务器 CORS 跨域问题
|
||||||
|
- 大文件点击卡死问题
|
||||||
|
- 收藏夹 bug 修复
|
||||||
|
- FileEditorPanel 语法错误
|
||||||
|
|
||||||
|
### 安全修复 🔒
|
||||||
|
- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)
|
||||||
|
- PDF 导出路径穿越防护
|
||||||
|
- PDF 导出标题 HTML 注入防护
|
||||||
|
|
||||||
|
### 重构 🔧
|
||||||
|
- CodeMirror 架构优化 — 统一导出避免多实例问题
|
||||||
|
- 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取
|
||||||
|
- 大规模死代码清理,显著减小包体积
|
||||||
|
- 配置加载超时保护(最多重试 30 次)
|
||||||
|
- 正则表达式预编译、缓存读锁优化
|
||||||
|
- 禁止 Ctrl+滚轮缩放
|
||||||
|
- Dockerfile 语法高亮支持
|
||||||
|
- 滚动条样式修复
|
||||||
|
|
||||||
|
### 文件系统 📁
|
||||||
|
- 右键菜单新增新建文件/文件夹
|
||||||
|
- FileEditorPanel 集成 PDF 导出按钮
|
||||||
|
- Markdown 文件自动预览与编辑/预览模式切换
|
||||||
|
- 面包屑导航组件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-02-05
|
||||||
|
|
||||||
|
### 重构 🔧
|
||||||
|
- **CodeMirror 架构优化** - 统一导出避免多实例问题
|
||||||
|
- **语言加载器优化** - 从动态 import 改为静态导入,提升稳定性
|
||||||
|
- **动态主题切换** - 使用 Compartment 实现无损切换
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- **编辑器性能** - 添加内容更新防抖,减少不必要的渲染
|
||||||
|
- **亮色主题** - 改进代码编辑器亮色模式样式
|
||||||
|
- **构建配置** - 简化 Vite 配置,优化打包效率
|
||||||
|
|
||||||
|
### 依赖清理 🧹
|
||||||
|
- 移除废弃的 `@codemirror/highlight` 包
|
||||||
|
- 移除不再使用的 `@codemirror/legacy-modes` 包
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.0] - 2026-02-04
|
## [0.3.0] - 2026-02-04
|
||||||
|
|
||||||
### 新增 ✨
|
### 新增 ✨
|
||||||
@@ -45,5 +198,3 @@
|
|||||||
- **主版本号** - 不兼容的 API 修改
|
- **主版本号** - 不兼容的 API 修改
|
||||||
- **次版本号** - 向下兼容的功能性新增
|
- **次版本号** - 向下兼容的功能性新增
|
||||||
- **修订号** - 向下兼容的问题修复
|
- **修订号** - 向下兼容的问题修复
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
185
README.md
@@ -1,155 +1,74 @@
|
|||||||
# U-Desk
|
# U-Desk
|
||||||
|
|
||||||
基于 Wails 的桌面应用程序,集成数据库客户端、文件管理、设备测试等功能。
|
桌面文件管理器,基于 [Wails v3](https://v3.wails.io/) (Go + Vue 3)。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 文件浏览 / 编辑 / 预览(文本、Markdown、图片、Office、PDF)
|
||||||
|
- 收藏夹管理(折叠/展开、拖拽排序、置顶)
|
||||||
|
- Markdown 编辑器(实时预览、语法高亮、Mermaid 图表)
|
||||||
|
- 远程文件服务器连接
|
||||||
|
- 主题切换(亮色/暗色)
|
||||||
|
- 版本更新检查
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **后端**:Go 1.25+、Wails v2
|
| 层 | 技术 |
|
||||||
- **前端**:Vue 3、Arco Design Vue、Vite
|
|---|------|
|
||||||
- **存储**:SQLite、MySQL、Redis、MongoDB
|
| 桌面框架 | Wails v3 (alpha.80) |
|
||||||
|
| 后端 | Go 1.22+ |
|
||||||
## 核心功能
|
| 前端 | Vue 3 + TypeScript |
|
||||||
|
| UI 组件库 | Arco Design Vue |
|
||||||
### 1. 数据库客户端
|
| 编辑器 | CodeMirror 6 |
|
||||||
- 支持 MySQL、Redis、MongoDB 多种数据库连接
|
| 构建 | Vite 7 + Taskfile |
|
||||||
- 连接管理(保存、编辑、删除连接配置)
|
|
||||||
- SQL 执行与结果展示
|
|
||||||
- 数据表结构查看
|
|
||||||
|
|
||||||
### 2. 文件管理
|
|
||||||
- 本地文件系统浏览(支持多盘符)
|
|
||||||
- 文件预览(图片、文本、代码)
|
|
||||||
- 文件操作(复制、移动、删除、重命名)
|
|
||||||
- 常用路径快捷访问(桌面、文档、下载等)
|
|
||||||
- 搜索与筛选功能
|
|
||||||
|
|
||||||
### 3. 设备测试
|
|
||||||
- 系统设备信息查询
|
|
||||||
- 硬件状态检测
|
|
||||||
|
|
||||||
### 4. 更新管理
|
|
||||||
- 应用版本检查与自动更新
|
|
||||||
- 更新日志展示
|
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
go-desk/
|
├── main.go # 入口:窗口配置、中间件、DevTools
|
||||||
├── app.go # 应用入口,API 方法绑定
|
├── app.go # 应用逻辑:文件系统、更新检查等
|
||||||
├── main.go # 程序启动
|
├── internal/ # 内部模块
|
||||||
├── wails.json # Wails 配置
|
│ ├── filesystem/ # 文件操作、锁、预览服务
|
||||||
├── go.mod # Go 模块依赖
|
│ └── api/ # API 处理器
|
||||||
├── internal/
|
├── frontend/ # 前端代码 (Vue 3)
|
||||||
│ ├── api/ # API 层(数据库、标签页、更新等)
|
│ ├── src/
|
||||||
│ ├── common/ # 通用工具(超时、工具函数)
|
│ │ ├── components/FileSystem/ # 文件管理主组件
|
||||||
│ ├── dbclient/ # 数据库客户端(MySQL、Redis、MongoDB)
|
│ │ ├── stores/ # Pinia 状态管理
|
||||||
│ ├── filesystem/ # 文件系统管理(模块化架构)
|
│ │ ├── api/ # 后端调用封装
|
||||||
│ ├── service/ # 服务层(SQL 执行等)
|
│ │ └── utils/ # 工具函数
|
||||||
│ ├── storage/ # 本地存储(SQLite)
|
│ └── vite.config.js
|
||||||
│ └── system/ # 系统信息获取
|
├── build/ # 构建配置(跨平台)
|
||||||
└── web/ # 前端代码
|
│ ├── config.yml # Wails 项目配置
|
||||||
├── package.json
|
│ └── windows/ # Windows 构建脚本
|
||||||
├── vite.config.js
|
└── configs/ # 运行时配置
|
||||||
├── index.html
|
|
||||||
└── src/
|
|
||||||
├── components/ # Vue 组件
|
|
||||||
│ ├── FileSystem.vue # 文件管理
|
|
||||||
│ ├── DeviceTest.vue # 设备测试
|
|
||||||
│ ├── UpdatePanel.vue # 更新面板
|
|
||||||
│ └── CodeEditor.vue # 代码编辑器
|
|
||||||
├── composables/ # 组合式函数
|
|
||||||
│ ├── useFileOperations.js
|
|
||||||
│ ├── useFavoriteFiles.js
|
|
||||||
│ └── useLocalStorage.js
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
├── api/ # API 调用
|
|
||||||
└── App.vue # 主应用
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Go 依赖
|
# 安装依赖
|
||||||
go mod tidy
|
wails3 task common:install:frontend:deps
|
||||||
|
|
||||||
# 前端依赖
|
# 启动开发模式(热重载)
|
||||||
cd web
|
wails3 dev
|
||||||
npm install
|
|
||||||
|
# 生产构建
|
||||||
|
wails3 build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 构建前端(必须)
|
### 构建标签
|
||||||
|
|
||||||
```bash
|
- `production` — 生产模式,使用嵌入的 frontend dist
|
||||||
cd web
|
- `devtools` — 在生产构建中保留 DevTools(F12)
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
**重要**:每次修改前端代码后都需要重新构建,Wails 使用 `web/dist` 目录中的构建产物。
|
## 快捷键
|
||||||
|
|
||||||
### 3. 开发模式运行
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| Ctrl+B | 切换侧边栏 |
|
||||||
|
| Ctrl+H | 历史记录 |
|
||||||
|
| Ctrl+F | 聚焦搜索 |
|
||||||
|
|
||||||
```bash
|
## 版本历史
|
||||||
# 在项目根目录
|
|
||||||
wails dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**:如果 `wails` 命令找不到,使用完整路径:
|
|
||||||
```bash
|
|
||||||
# 获取 GOPATH
|
|
||||||
go env GOPATH
|
|
||||||
|
|
||||||
# 使用完整路径(根据你的 GOPATH 调整)
|
|
||||||
D:\Go\go-workspace\bin\wails.exe dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 构建应用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 确保前端已构建
|
|
||||||
cd web
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# 构建当前平台
|
|
||||||
wails build
|
|
||||||
|
|
||||||
# 构建 Windows(明确指定平台)
|
|
||||||
wails build -platform windows/amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
**构建产物位置**:`build/bin/go-desk.exe`
|
|
||||||
|
|
||||||
**注意**:
|
|
||||||
- 构建前确保前端已构建(`web/dist` 目录存在)
|
|
||||||
- 构建产物是独立的可执行文件,包含前端资源
|
|
||||||
|
|
||||||
## 数据库配置
|
|
||||||
|
|
||||||
应用使用 SQLite 本地存储连接配置和用户数据。
|
|
||||||
|
|
||||||
可选连接外部数据库:
|
|
||||||
- **MySQL**:支持连接、查询、表结构查看
|
|
||||||
- **Redis**:支持连接、基础操作
|
|
||||||
- **MongoDB**:支持连接、基础操作
|
|
||||||
|
|
||||||
## 架构特点
|
|
||||||
|
|
||||||
- **模块化文件系统**:文件管理功能采用模块化设计,职责分离
|
|
||||||
- **异步启动优化**:应用启动流程优化,核心功能快速初始化
|
|
||||||
- **本地文件服务器**:支持本地文件预览和访问
|
|
||||||
- **SQLite 持久化**:连接配置和用户数据本地存储
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
详细文档请查看 `docs/` 目录:
|
|
||||||
- 架构设计文档
|
|
||||||
- 功能迭代记录
|
|
||||||
- 技术决策记录(ADR)
|
|
||||||
- 测试用例和检查报告
|
|
||||||
|
|
||||||
## 许可
|
|
||||||
|
|
||||||
本项目用于学习和测试目的。
|
|
||||||
|
|
||||||
|
详见 [CHANGELOG.md](./CHANGELOG.md)
|
||||||
|
|||||||
60
Taskfile.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ./build/Taskfile.yml
|
||||||
|
windows: ./build/windows/Taskfile.yml
|
||||||
|
darwin: ./build/darwin/Taskfile.yml
|
||||||
|
linux: ./build/linux/Taskfile.yml
|
||||||
|
ios: ./build/ios/Taskfile.yml
|
||||||
|
android: ./build/android/Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
APP_NAME: "u-desk"
|
||||||
|
BIN_DIR: "bin"
|
||||||
|
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{OS}}:build"
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{OS}}:package"
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Runs the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{OS}}:run"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
summary: Runs the application in development mode
|
||||||
|
cmds:
|
||||||
|
- wails3 dev
|
||||||
|
|
||||||
|
setup:docker:
|
||||||
|
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||||
|
cmds:
|
||||||
|
- task: common:setup:docker
|
||||||
|
|
||||||
|
build:server:
|
||||||
|
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||||
|
cmds:
|
||||||
|
- task: common:build:server
|
||||||
|
|
||||||
|
run:server:
|
||||||
|
summary: Runs the application in server mode
|
||||||
|
cmds:
|
||||||
|
- task: common:run:server
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds a Docker image for server mode deployment
|
||||||
|
cmds:
|
||||||
|
- task: common:build:docker
|
||||||
|
|
||||||
|
run:docker:
|
||||||
|
summary: Builds and runs the Docker image
|
||||||
|
cmds:
|
||||||
|
- task: common:run:docker
|
||||||
253
build/Taskfile.yml
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
go:mod:tidy:
|
||||||
|
summary: Runs `go mod tidy`
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
install:frontend:deps:
|
||||||
|
summary: Install frontend dependencies
|
||||||
|
dir: frontend
|
||||||
|
sources:
|
||||||
|
- package.json
|
||||||
|
- package-lock.json
|
||||||
|
generates:
|
||||||
|
- node_modules
|
||||||
|
preconditions:
|
||||||
|
- sh: npm version
|
||||||
|
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
|
||||||
|
cmds:
|
||||||
|
- npm install
|
||||||
|
|
||||||
|
build:frontend:
|
||||||
|
label: build:frontend (DEV={{.DEV}})
|
||||||
|
summary: Build the frontend project
|
||||||
|
dir: frontend
|
||||||
|
sources:
|
||||||
|
- "**/*"
|
||||||
|
- exclude: node_modules/**/*
|
||||||
|
generates:
|
||||||
|
- dist/**/*
|
||||||
|
deps:
|
||||||
|
- task: install:frontend:deps
|
||||||
|
- task: generate:bindings
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
cmds:
|
||||||
|
- npm run {{.BUILD_COMMAND}} -q
|
||||||
|
env:
|
||||||
|
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||||
|
vars:
|
||||||
|
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||||
|
|
||||||
|
|
||||||
|
frontend:vendor:puppertino:
|
||||||
|
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
|
||||||
|
sources:
|
||||||
|
- frontend/public/puppertino/puppertino.css
|
||||||
|
generates:
|
||||||
|
- frontend/public/puppertino/puppertino.css
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p frontend/public/puppertino
|
||||||
|
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
|
||||||
|
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
|
||||||
|
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
|
||||||
|
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
|
||||||
|
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
|
||||||
|
else
|
||||||
|
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
|
||||||
|
fi
|
||||||
|
# Ensure index.html includes Puppertino CSS and button classes
|
||||||
|
INDEX_HTML=frontend/index.html
|
||||||
|
if [ -f "$INDEX_HTML" ]; then
|
||||||
|
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
|
||||||
|
# Insert Puppertino link tag after style.css link
|
||||||
|
awk '
|
||||||
|
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
|
||||||
|
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
|
||||||
|
fi
|
||||||
|
# Replace default .btn with Puppertino primary button classes if present
|
||||||
|
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
generate:bindings:
|
||||||
|
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
|
||||||
|
summary: Generates bindings for the frontend
|
||||||
|
deps:
|
||||||
|
- task: go:mod:tidy
|
||||||
|
sources:
|
||||||
|
- "**/*.[jt]s"
|
||||||
|
- exclude: frontend/**/*
|
||||||
|
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
cmds:
|
||||||
|
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts
|
||||||
|
|
||||||
|
generate:icons:
|
||||||
|
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
|
||||||
|
dir: build
|
||||||
|
sources:
|
||||||
|
- "appicon.png"
|
||||||
|
- "appicon.icon"
|
||||||
|
generates:
|
||||||
|
- "darwin/icons.icns"
|
||||||
|
- "windows/icon.ico"
|
||||||
|
cmds:
|
||||||
|
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
|
||||||
|
|
||||||
|
dev:frontend:
|
||||||
|
summary: Runs the frontend in development mode
|
||||||
|
dir: frontend
|
||||||
|
deps:
|
||||||
|
- task: install:frontend:deps
|
||||||
|
cmds:
|
||||||
|
- npm run dev -- --port {{.VITE_PORT}} --strictPort
|
||||||
|
|
||||||
|
update:build-assets:
|
||||||
|
summary: Updates the build assets
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||||
|
|
||||||
|
build:server:
|
||||||
|
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||||
|
desc: |
|
||||||
|
Builds the application with the server build tag enabled.
|
||||||
|
Server mode runs as a pure HTTP server without native GUI dependencies.
|
||||||
|
Usage: task build:server
|
||||||
|
deps:
|
||||||
|
- task: build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
cmds:
|
||||||
|
- go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: "{{.BUILD_FLAGS}}"
|
||||||
|
|
||||||
|
run:server:
|
||||||
|
summary: Builds and runs the application in server mode
|
||||||
|
deps:
|
||||||
|
- task: build:server
|
||||||
|
cmds:
|
||||||
|
- ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds a Docker image for server mode deployment
|
||||||
|
desc: |
|
||||||
|
Creates a minimal Docker image containing the server mode binary.
|
||||||
|
The image is based on distroless for security and small size.
|
||||||
|
Usage: task build:docker [TAG=myapp:latest]
|
||||||
|
cmds:
|
||||||
|
- docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
|
||||||
|
vars:
|
||||||
|
TAG: "{{.TAG}}"
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required. Please install Docker first."
|
||||||
|
- sh: test -f build/docker/Dockerfile.server
|
||||||
|
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
|
||||||
|
|
||||||
|
run:docker:
|
||||||
|
summary: Builds and runs the Docker image
|
||||||
|
desc: |
|
||||||
|
Builds the Docker image and runs it, exposing port 8080.
|
||||||
|
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
|
||||||
|
Note: The internal container port is always 8080. The PORT variable
|
||||||
|
only changes the host port mapping. Ensure your app uses port 8080
|
||||||
|
or modify the Dockerfile to match your ServerOptions.Port setting.
|
||||||
|
deps:
|
||||||
|
- task: build:docker
|
||||||
|
vars:
|
||||||
|
TAG:
|
||||||
|
ref: .TAG
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
|
||||||
|
vars:
|
||||||
|
TAG: "{{.TAG}}"
|
||||||
|
PORT: "{{.PORT}}"
|
||||||
|
|
||||||
|
setup:docker:
|
||||||
|
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||||
|
desc: |
|
||||||
|
Builds the Docker image needed for cross-compiling to any platform.
|
||||||
|
Run this once to enable cross-platform builds from any OS.
|
||||||
|
cmds:
|
||||||
|
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required. Please install Docker first."
|
||||||
|
|
||||||
|
ios:device:list:
|
||||||
|
summary: Lists connected iOS devices (UDIDs)
|
||||||
|
cmds:
|
||||||
|
- xcrun xcdevice list
|
||||||
|
|
||||||
|
ios:run:device:
|
||||||
|
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
|
||||||
|
vars:
|
||||||
|
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
|
||||||
|
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
|
||||||
|
CONFIG: '{{.CONFIG | default "Debug"}}'
|
||||||
|
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
|
||||||
|
UDID: '{{.UDID}}' # from `task ios:device:list`
|
||||||
|
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
|
||||||
|
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
|
||||||
|
preconditions:
|
||||||
|
- sh: xcrun -f xcodebuild
|
||||||
|
msg: "xcodebuild not found. Please install Xcode."
|
||||||
|
- sh: xcrun -f devicectl
|
||||||
|
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
|
||||||
|
- sh: test -n '{{.PROJECT}}'
|
||||||
|
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
|
||||||
|
- sh: test -n '{{.SCHEME}}'
|
||||||
|
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
|
||||||
|
- sh: test -n '{{.UDID}}'
|
||||||
|
msg: "Set UDID to your device UDID (see: task ios:device:list)."
|
||||||
|
- sh: test -n '{{.BUNDLE_ID}}'
|
||||||
|
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
|
||||||
|
XCB_ARGS=(
|
||||||
|
-project "{{.PROJECT}}"
|
||||||
|
-scheme "{{.SCHEME}}"
|
||||||
|
-configuration "{{.CONFIG}}"
|
||||||
|
-destination "id={{.UDID}}"
|
||||||
|
-derivedDataPath "{{.DERIVED}}"
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
-allowProvisioningDeviceRegistration
|
||||||
|
)
|
||||||
|
# Optionally inject signing identifiers if provided
|
||||||
|
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
|
||||||
|
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
|
||||||
|
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
|
||||||
|
# If xcpretty isn't installed, run without it
|
||||||
|
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||||
|
xcodebuild "${XCB_ARGS[@]}" build
|
||||||
|
fi
|
||||||
|
# Find built .app
|
||||||
|
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
|
||||||
|
if [ -z "$APP_PATH" ]; then
|
||||||
|
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing: $APP_PATH"
|
||||||
|
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
|
||||||
|
echo "Launching: {{.BUNDLE_ID}}"
|
||||||
|
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
|
||||||
237
build/android/Taskfile.yml
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
APP_ID: '{{.APP_ID | default "com.wails.app"}}'
|
||||||
|
MIN_SDK: '21'
|
||||||
|
TARGET_SDK: '34'
|
||||||
|
NDK_VERSION: 'r26d'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
install:deps:
|
||||||
|
summary: Check and install Android development dependencies
|
||||||
|
cmds:
|
||||||
|
- go run build/android/scripts/deps/install_deps.go
|
||||||
|
env:
|
||||||
|
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
|
||||||
|
prompt: This will check and install Android development dependencies. Continue?
|
||||||
|
|
||||||
|
build:
|
||||||
|
summary: Creates a build of the application for Android
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: generate:android:bindings
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
PRODUCTION:
|
||||||
|
ref: .PRODUCTION
|
||||||
|
- task: common:generate:icons
|
||||||
|
cmds:
|
||||||
|
- echo "Building Android app {{.APP_NAME}}..."
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
env:
|
||||||
|
PRODUCTION: '{{.PRODUCTION | default "false"}}'
|
||||||
|
|
||||||
|
compile:go:shared:
|
||||||
|
summary: Compile Go code to shared library (.so)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
NDK_ROOT="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/{{.NDK_VERSION}}}"
|
||||||
|
if [ ! -d "$NDK_ROOT" ]; then
|
||||||
|
echo "Error: Android NDK not found at $NDK_ROOT"
|
||||||
|
echo "Please set ANDROID_NDK_HOME or install NDK {{.NDK_VERSION}} via Android Studio"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine toolchain based on host OS
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) HOST_TAG="darwin-x86_64" ;;
|
||||||
|
Linux) HOST_TAG="linux-x86_64" ;;
|
||||||
|
*) echo "Unsupported host OS"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||||
|
|
||||||
|
# Set compiler based on architecture
|
||||||
|
case "{{.ARCH}}" in
|
||||||
|
arm64)
|
||||||
|
export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang"
|
||||||
|
export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++"
|
||||||
|
export GOARCH=arm64
|
||||||
|
JNI_DIR="arm64-v8a"
|
||||||
|
;;
|
||||||
|
amd64|x86_64)
|
||||||
|
export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang"
|
||||||
|
export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++"
|
||||||
|
export GOARCH=amd64
|
||||||
|
JNI_DIR="x86_64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: {{.ARCH}}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=android
|
||||||
|
|
||||||
|
mkdir -p {{.BIN_DIR}}
|
||||||
|
mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR
|
||||||
|
|
||||||
|
go build -buildmode=c-shared {{.BUILD_FLAGS}} \
|
||||||
|
-o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
|
||||||
|
compile:go:all-archs:
|
||||||
|
summary: Compile Go code for all Android architectures (fat APK)
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
- task: compile:go:shared
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application into an APK
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk
|
||||||
|
|
||||||
|
package:fat:
|
||||||
|
summary: Packages a production build for all architectures (fat APK)
|
||||||
|
cmds:
|
||||||
|
- task: compile:go:all-archs
|
||||||
|
- task: assemble:apk
|
||||||
|
|
||||||
|
assemble:apk:
|
||||||
|
summary: Assembles the APK using Gradle
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
|
||||||
|
assemble:apk:release:
|
||||||
|
summary: Assembles a release APK using Gradle
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
cd build/android
|
||||||
|
./gradlew assembleRelease
|
||||||
|
cp app/build/outputs/apk/release/app-release-unsigned.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}-release.apk"
|
||||||
|
echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}-release.apk"
|
||||||
|
|
||||||
|
generate:android:bindings:
|
||||||
|
internal: true
|
||||||
|
summary: Generates bindings for Android
|
||||||
|
sources:
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
cmds:
|
||||||
|
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
|
||||||
|
env:
|
||||||
|
GOOS: android
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
|
||||||
|
ensure-emulator:
|
||||||
|
internal: true
|
||||||
|
summary: Ensure Android Emulator is running
|
||||||
|
silent: true
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
# Check if an emulator is already running
|
||||||
|
if adb devices | grep -q "emulator"; then
|
||||||
|
echo "Emulator already running"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get first available AVD
|
||||||
|
AVD_NAME=$(emulator -list-avds | head -1)
|
||||||
|
if [ -z "$AVD_NAME" ]; then
|
||||||
|
echo "No Android Virtual Devices found."
|
||||||
|
echo "Create one using: Android Studio > Tools > Device Manager"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting emulator: $AVD_NAME"
|
||||||
|
emulator -avd "$AVD_NAME" -no-snapshot-load &
|
||||||
|
|
||||||
|
# Wait for emulator to boot (max 60 seconds)
|
||||||
|
echo "Waiting for emulator to boot..."
|
||||||
|
adb wait-for-device
|
||||||
|
|
||||||
|
for i in {1..60}; do
|
||||||
|
BOOT_COMPLETED=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
|
||||||
|
if [ "$BOOT_COMPLETED" = "1" ]; then
|
||||||
|
echo "Emulator booted successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Emulator boot timeout"
|
||||||
|
exit 1
|
||||||
|
preconditions:
|
||||||
|
- sh: command -v adb
|
||||||
|
msg: "adb not found. Please install Android SDK and add platform-tools to PATH"
|
||||||
|
- sh: command -v emulator
|
||||||
|
msg: "emulator not found. Please install Android SDK and add emulator to PATH"
|
||||||
|
|
||||||
|
deploy-emulator:
|
||||||
|
summary: Deploy to Android Emulator
|
||||||
|
deps: [package]
|
||||||
|
cmds:
|
||||||
|
- adb uninstall {{.APP_ID}} 2>/dev/null || true
|
||||||
|
- adb install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
- adb shell am start -n {{.APP_ID}}/.MainActivity
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Run the application in Android Emulator
|
||||||
|
deps:
|
||||||
|
- task: ensure-emulator
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: x86_64
|
||||||
|
cmds:
|
||||||
|
- task: assemble:apk
|
||||||
|
- adb uninstall {{.APP_ID}} 2>/dev/null || true
|
||||||
|
- adb install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||||
|
- adb shell am start -n {{.APP_ID}}/.MainActivity
|
||||||
|
|
||||||
|
logs:
|
||||||
|
summary: Stream Android logcat filtered to this app
|
||||||
|
cmds:
|
||||||
|
- adb logcat -v time | grep -E "(Wails|{{.APP_NAME}})"
|
||||||
|
|
||||||
|
logs:all:
|
||||||
|
summary: Stream all Android logcat (verbose)
|
||||||
|
cmds:
|
||||||
|
- adb logcat -v time
|
||||||
|
|
||||||
|
clean:
|
||||||
|
summary: Clean build artifacts
|
||||||
|
cmds:
|
||||||
|
- rm -rf {{.BIN_DIR}}
|
||||||
|
- rm -rf build/android/app/build
|
||||||
|
- rm -rf build/android/app/src/main/jniLibs/*/libwails.so
|
||||||
|
- rm -rf build/android/.gradle
|
||||||
63
build/android/app/build.gradle
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.wails.app'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.wails.app"
|
||||||
|
minSdk 21
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
|
||||||
|
// Configure supported ABIs
|
||||||
|
ndk {
|
||||||
|
abiFilters 'arm64-v8a', 'x86_64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
debuggable true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source sets configuration
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
// JNI libraries are in jniLibs folder
|
||||||
|
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||||
|
// Assets for the WebView
|
||||||
|
assets.srcDirs = ['src/main/assets']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packaging options
|
||||||
|
packagingOptions {
|
||||||
|
// Don't strip Go symbols in debug builds
|
||||||
|
doNotStrip '*/arm64-v8a/libwails.so'
|
||||||
|
doNotStrip '*/x86_64/libwails.so'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'androidx.webkit:webkit:1.9.0'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
}
|
||||||
12
build/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
|
||||||
|
# Keep native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Wails bridge classes
|
||||||
|
-keep class com.wails.app.WailsBridge { *; }
|
||||||
|
-keep class com.wails.app.WailsJSBridge { *; }
|
||||||
30
build/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Internet permission for WebView -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.WailsApp"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
198
build/android/app/src/main/java/com/wails/app/MainActivity.java
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebResourceRequest;
|
||||||
|
import android.webkit.WebResourceResponse;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.webkit.WebViewAssetLoader;
|
||||||
|
import com.wails.app.BuildConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MainActivity hosts the WebView and manages the Wails application lifecycle.
|
||||||
|
* It uses WebViewAssetLoader to serve assets from the Go library without
|
||||||
|
* requiring a network server.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
private static final String TAG = "WailsActivity";
|
||||||
|
private static final String WAILS_SCHEME = "https";
|
||||||
|
private static final String WAILS_HOST = "wails.localhost";
|
||||||
|
|
||||||
|
private WebView webView;
|
||||||
|
private WailsBridge bridge;
|
||||||
|
private WebViewAssetLoader assetLoader;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_main);
|
||||||
|
|
||||||
|
// Initialize the native Go library
|
||||||
|
bridge = new WailsBridge(this);
|
||||||
|
bridge.initialize();
|
||||||
|
|
||||||
|
// Set up WebView
|
||||||
|
setupWebView();
|
||||||
|
|
||||||
|
// Load the application
|
||||||
|
loadApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private void setupWebView() {
|
||||||
|
webView = findViewById(R.id.webview);
|
||||||
|
|
||||||
|
// Configure WebView settings
|
||||||
|
WebSettings settings = webView.getSettings();
|
||||||
|
settings.setJavaScriptEnabled(true);
|
||||||
|
settings.setDomStorageEnabled(true);
|
||||||
|
settings.setDatabaseEnabled(true);
|
||||||
|
settings.setAllowFileAccess(false);
|
||||||
|
settings.setAllowContentAccess(false);
|
||||||
|
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||||
|
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||||
|
|
||||||
|
// Enable debugging in debug builds
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
WebView.setWebContentsDebuggingEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up asset loader for serving local assets
|
||||||
|
assetLoader = new WebViewAssetLoader.Builder()
|
||||||
|
.setDomain(WAILS_HOST)
|
||||||
|
.addPathHandler("/", new WailsPathHandler(bridge))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Set up WebView client to intercept requests
|
||||||
|
webView.setWebViewClient(new WebViewClient() {
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||||
|
String url = request.getUrl().toString();
|
||||||
|
Log.d(TAG, "Intercepting request: " + url);
|
||||||
|
|
||||||
|
// Handle wails.localhost requests
|
||||||
|
if (request.getUrl().getHost() != null &&
|
||||||
|
request.getUrl().getHost().equals(WAILS_HOST)) {
|
||||||
|
|
||||||
|
// For wails API calls (runtime, capabilities, etc.), we need to pass the full URL
|
||||||
|
// including query string because WebViewAssetLoader.PathHandler strips query params
|
||||||
|
String path = request.getUrl().getPath();
|
||||||
|
if (path != null && path.startsWith("/wails/")) {
|
||||||
|
// Get full path with query string for runtime calls
|
||||||
|
String fullPath = path;
|
||||||
|
String query = request.getUrl().getQuery();
|
||||||
|
if (query != null && !query.isEmpty()) {
|
||||||
|
fullPath = path + "?" + query;
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Wails API call detected, full path: " + fullPath);
|
||||||
|
|
||||||
|
// Call bridge directly with full path
|
||||||
|
byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}");
|
||||||
|
if (data != null && data.length > 0) {
|
||||||
|
java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data);
|
||||||
|
java.util.Map<String, String> headers = new java.util.HashMap<>();
|
||||||
|
headers.put("Access-Control-Allow-Origin", "*");
|
||||||
|
headers.put("Cache-Control", "no-cache");
|
||||||
|
headers.put("Content-Type", "application/json");
|
||||||
|
|
||||||
|
return new WebResourceResponse(
|
||||||
|
"application/json",
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
headers,
|
||||||
|
inputStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Return error response if data is null
|
||||||
|
return new WebResourceResponse(
|
||||||
|
"application/json",
|
||||||
|
"UTF-8",
|
||||||
|
500,
|
||||||
|
"Internal Error",
|
||||||
|
new java.util.HashMap<>(),
|
||||||
|
new java.io.ByteArrayInputStream("{}".getBytes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular assets, use the asset loader
|
||||||
|
return assetLoader.shouldInterceptRequest(request.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.shouldInterceptRequest(view, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageFinished(WebView view, String url) {
|
||||||
|
super.onPageFinished(view, url);
|
||||||
|
Log.d(TAG, "Page loaded: " + url);
|
||||||
|
// Inject Wails runtime
|
||||||
|
bridge.injectRuntime(webView, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add JavaScript interface for Go communication
|
||||||
|
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadApplication() {
|
||||||
|
// Load the main page from the asset server
|
||||||
|
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
|
||||||
|
Log.d(TAG, "Loading URL: " + url);
|
||||||
|
webView.loadUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute JavaScript in the WebView from the Go side
|
||||||
|
*/
|
||||||
|
public void executeJavaScript(final String js) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (webView != null) {
|
||||||
|
webView.evaluateJavascript(js, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onResume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.onPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.shutdown();
|
||||||
|
}
|
||||||
|
if (webView != null) {
|
||||||
|
webView.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
if (webView != null && webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
} else {
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
build/android/app/src/main/java/com/wails/app/WailsBridge.java
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsBridge manages the connection between the Java/Android side and the Go native library.
|
||||||
|
* It handles:
|
||||||
|
* - Loading and initializing the native Go library
|
||||||
|
* - Serving asset requests from Go
|
||||||
|
* - Passing messages between JavaScript and Go
|
||||||
|
* - Managing callbacks for async operations
|
||||||
|
*/
|
||||||
|
public class WailsBridge {
|
||||||
|
private static final String TAG = "WailsBridge";
|
||||||
|
|
||||||
|
static {
|
||||||
|
// Load the native Go library
|
||||||
|
System.loadLibrary("wails");
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final AtomicInteger callbackIdGenerator = new AtomicInteger(0);
|
||||||
|
private final ConcurrentHashMap<Integer, AssetCallback> pendingAssetCallbacks = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<Integer, MessageCallback> pendingMessageCallbacks = new ConcurrentHashMap<>();
|
||||||
|
private WebView webView;
|
||||||
|
private volatile boolean initialized = false;
|
||||||
|
|
||||||
|
// Native methods - implemented in Go
|
||||||
|
private static native void nativeInit(WailsBridge bridge);
|
||||||
|
private static native void nativeShutdown();
|
||||||
|
private static native void nativeOnResume();
|
||||||
|
private static native void nativeOnPause();
|
||||||
|
private static native void nativeOnPageFinished(String url);
|
||||||
|
private static native byte[] nativeServeAsset(String path, String method, String headers);
|
||||||
|
private static native String nativeHandleMessage(String message);
|
||||||
|
private static native String nativeGetAssetMimeType(String path);
|
||||||
|
|
||||||
|
public WailsBridge(Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the native Go library
|
||||||
|
*/
|
||||||
|
public void initialize() {
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Initializing Wails bridge...");
|
||||||
|
try {
|
||||||
|
nativeInit(this);
|
||||||
|
initialized = true;
|
||||||
|
Log.i(TAG, "Wails bridge initialized successfully");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Failed to initialize Wails bridge", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the native Go library
|
||||||
|
*/
|
||||||
|
public void shutdown() {
|
||||||
|
if (!initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Shutting down Wails bridge...");
|
||||||
|
try {
|
||||||
|
nativeShutdown();
|
||||||
|
initialized = false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error during shutdown", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the activity resumes
|
||||||
|
*/
|
||||||
|
public void onResume() {
|
||||||
|
if (initialized) {
|
||||||
|
nativeOnResume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the activity pauses
|
||||||
|
*/
|
||||||
|
public void onPause() {
|
||||||
|
if (initialized) {
|
||||||
|
nativeOnPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve an asset from the Go asset server
|
||||||
|
* @param path The URL path requested
|
||||||
|
* @param method The HTTP method
|
||||||
|
* @param headers The request headers as JSON
|
||||||
|
* @return The asset data, or null if not found
|
||||||
|
*/
|
||||||
|
public byte[] serveAsset(String path, String method, String headers) {
|
||||||
|
if (!initialized) {
|
||||||
|
Log.w(TAG, "Bridge not initialized, cannot serve asset: " + path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Serving asset: " + path);
|
||||||
|
try {
|
||||||
|
return nativeServeAsset(path, method, headers);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error serving asset: " + path, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the MIME type for an asset
|
||||||
|
* @param path The asset path
|
||||||
|
* @return The MIME type string
|
||||||
|
*/
|
||||||
|
public String getAssetMimeType(String path) {
|
||||||
|
if (!initialized) {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String mimeType = nativeGetAssetMimeType(path);
|
||||||
|
return mimeType != null ? mimeType : "application/octet-stream";
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error getting MIME type for: " + path, e);
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a message from JavaScript
|
||||||
|
* @param message The message from JavaScript (JSON)
|
||||||
|
* @return The response to send back to JavaScript (JSON)
|
||||||
|
*/
|
||||||
|
public String handleMessage(String message) {
|
||||||
|
if (!initialized) {
|
||||||
|
Log.w(TAG, "Bridge not initialized, cannot handle message");
|
||||||
|
return "{\"error\":\"Bridge not initialized\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Handling message from JS: " + message);
|
||||||
|
try {
|
||||||
|
return nativeHandleMessage(message);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error handling message", e);
|
||||||
|
return "{\"error\":\"" + e.getMessage() + "\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject the Wails runtime JavaScript into the WebView.
|
||||||
|
* Called when the page finishes loading.
|
||||||
|
* @param webView The WebView to inject into
|
||||||
|
* @param url The URL that finished loading
|
||||||
|
*/
|
||||||
|
public void injectRuntime(WebView webView, String url) {
|
||||||
|
this.webView = webView;
|
||||||
|
// Notify Go side that page has finished loading so it can inject the runtime
|
||||||
|
Log.d(TAG, "Page finished loading: " + url + ", notifying Go side");
|
||||||
|
if (initialized) {
|
||||||
|
nativeOnPageFinished(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute JavaScript in the WebView (called from Go side)
|
||||||
|
* @param js The JavaScript code to execute
|
||||||
|
*/
|
||||||
|
public void executeJavaScript(String js) {
|
||||||
|
if (webView != null) {
|
||||||
|
webView.post(() -> webView.evaluateJavascript(js, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from Go when an event needs to be emitted to JavaScript
|
||||||
|
* @param eventName The event name
|
||||||
|
* @param eventData The event data (JSON)
|
||||||
|
*/
|
||||||
|
public void emitEvent(String eventName, String eventData) {
|
||||||
|
String js = String.format("window.wails && window.wails._emit('%s', %s);",
|
||||||
|
escapeJsString(eventName), eventData);
|
||||||
|
executeJavaScript(js);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJsString(String str) {
|
||||||
|
return str.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback interfaces
|
||||||
|
public interface AssetCallback {
|
||||||
|
void onAssetReady(byte[] data, String mimeType);
|
||||||
|
void onAssetError(String error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface MessageCallback {
|
||||||
|
void onResponse(String response);
|
||||||
|
void onError(String error);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
build/android/app/src/main/java/com/wails/app/WailsJSBridge.java
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.JavascriptInterface;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import com.wails.app.BuildConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsJSBridge provides the JavaScript interface that allows the web frontend
|
||||||
|
* to communicate with the Go backend. This is exposed to JavaScript as the
|
||||||
|
* `window.wails` object.
|
||||||
|
*
|
||||||
|
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
|
||||||
|
*/
|
||||||
|
public class WailsJSBridge {
|
||||||
|
private static final String TAG = "WailsJSBridge";
|
||||||
|
|
||||||
|
private final WailsBridge bridge;
|
||||||
|
private final WebView webView;
|
||||||
|
|
||||||
|
public WailsJSBridge(WailsBridge bridge, WebView webView) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.webView = webView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Go and return the response synchronously.
|
||||||
|
* Called from JavaScript: wails.invoke(message)
|
||||||
|
*
|
||||||
|
* @param message The message to send (JSON string)
|
||||||
|
* @return The response from Go (JSON string)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public String invoke(String message) {
|
||||||
|
Log.d(TAG, "Invoke called: " + message);
|
||||||
|
return bridge.handleMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Go asynchronously.
|
||||||
|
* The response will be sent back via a callback.
|
||||||
|
* Called from JavaScript: wails.invokeAsync(callbackId, message)
|
||||||
|
*
|
||||||
|
* @param callbackId The callback ID to use for the response
|
||||||
|
* @param message The message to send (JSON string)
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public void invokeAsync(final String callbackId, final String message) {
|
||||||
|
Log.d(TAG, "InvokeAsync called: " + message);
|
||||||
|
|
||||||
|
// Handle in background thread to not block JavaScript
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
String response = bridge.handleMessage(message);
|
||||||
|
sendCallback(callbackId, response, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error in async invoke", e);
|
||||||
|
sendCallback(callbackId, null, e.getMessage());
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a message from JavaScript to Android's logcat
|
||||||
|
* Called from JavaScript: wails.log(level, message)
|
||||||
|
*
|
||||||
|
* @param level The log level (debug, info, warn, error)
|
||||||
|
* @param message The message to log
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public void log(String level, String message) {
|
||||||
|
switch (level.toLowerCase()) {
|
||||||
|
case "debug":
|
||||||
|
Log.d(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
Log.i(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
Log.w(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
Log.e(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Log.v(TAG + "/JS", message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the platform name
|
||||||
|
* Called from JavaScript: wails.platform()
|
||||||
|
*
|
||||||
|
* @return "android"
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public String platform() {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're running in debug mode
|
||||||
|
* Called from JavaScript: wails.isDebug()
|
||||||
|
*
|
||||||
|
* @return true if debug build, false otherwise
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
public boolean isDebug() {
|
||||||
|
return BuildConfig.DEBUG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a callback response to JavaScript
|
||||||
|
*/
|
||||||
|
private void sendCallback(String callbackId, String result, String error) {
|
||||||
|
final String js;
|
||||||
|
if (error != null) {
|
||||||
|
js = String.format(
|
||||||
|
"window.wails && window.wails._callback('%s', null, '%s');",
|
||||||
|
escapeJsString(callbackId),
|
||||||
|
escapeJsString(error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
js = String.format(
|
||||||
|
"window.wails && window.wails._callback('%s', %s, null);",
|
||||||
|
escapeJsString(callbackId),
|
||||||
|
result != null ? result : "null"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.post(() -> webView.evaluateJavascript(js, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJsString(String str) {
|
||||||
|
if (str == null) return "";
|
||||||
|
return str.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.wails.app;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebResourceResponse;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.webkit.WebViewAssetLoader;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
|
||||||
|
* from the Go asset server. This allows the WebView to load assets without
|
||||||
|
* using a network server, similar to iOS's WKURLSchemeHandler.
|
||||||
|
*/
|
||||||
|
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
|
||||||
|
private static final String TAG = "WailsPathHandler";
|
||||||
|
|
||||||
|
private final WailsBridge bridge;
|
||||||
|
|
||||||
|
public WailsPathHandler(WailsBridge bridge) {
|
||||||
|
this.bridge = bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse handle(@NonNull String path) {
|
||||||
|
Log.d(TAG, "Handling path: " + path);
|
||||||
|
|
||||||
|
// Normalize path
|
||||||
|
if (path.isEmpty() || path.equals("/")) {
|
||||||
|
path = "/index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get asset from Go
|
||||||
|
byte[] data = bridge.serveAsset(path, "GET", "{}");
|
||||||
|
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
Log.w(TAG, "Asset not found: " + path);
|
||||||
|
return null; // Return null to let WebView handle 404
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
String mimeType = bridge.getAssetMimeType(path);
|
||||||
|
Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
|
||||||
|
|
||||||
|
// Create response
|
||||||
|
InputStream inputStream = new ByteArrayInputStream(data);
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("Access-Control-Allow-Origin", "*");
|
||||||
|
headers.put("Cache-Control", "no-cache");
|
||||||
|
|
||||||
|
return new WebResourceResponse(
|
||||||
|
mimeType,
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
headers,
|
||||||
|
inputStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine MIME type from file extension
|
||||||
|
*/
|
||||||
|
private String getMimeType(String path) {
|
||||||
|
String lowerPath = path.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) {
|
||||||
|
return "text/html";
|
||||||
|
} else if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs")) {
|
||||||
|
return "application/javascript";
|
||||||
|
} else if (lowerPath.endsWith(".css")) {
|
||||||
|
return "text/css";
|
||||||
|
} else if (lowerPath.endsWith(".json")) {
|
||||||
|
return "application/json";
|
||||||
|
} else if (lowerPath.endsWith(".png")) {
|
||||||
|
return "image/png";
|
||||||
|
} else if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
|
||||||
|
return "image/jpeg";
|
||||||
|
} else if (lowerPath.endsWith(".gif")) {
|
||||||
|
return "image/gif";
|
||||||
|
} else if (lowerPath.endsWith(".svg")) {
|
||||||
|
return "image/svg+xml";
|
||||||
|
} else if (lowerPath.endsWith(".ico")) {
|
||||||
|
return "image/x-icon";
|
||||||
|
} else if (lowerPath.endsWith(".woff")) {
|
||||||
|
return "font/woff";
|
||||||
|
} else if (lowerPath.endsWith(".woff2")) {
|
||||||
|
return "font/woff2";
|
||||||
|
} else if (lowerPath.endsWith(".ttf")) {
|
||||||
|
return "font/ttf";
|
||||||
|
} else if (lowerPath.endsWith(".eot")) {
|
||||||
|
return "application/vnd.ms-fontobject";
|
||||||
|
} else if (lowerPath.endsWith(".xml")) {
|
||||||
|
return "application/xml";
|
||||||
|
} else if (lowerPath.endsWith(".txt")) {
|
||||||
|
return "text/plain";
|
||||||
|
} else if (lowerPath.endsWith(".wasm")) {
|
||||||
|
return "application/wasm";
|
||||||
|
} else if (lowerPath.endsWith(".mp3")) {
|
||||||
|
return "audio/mpeg";
|
||||||
|
} else if (lowerPath.endsWith(".mp4")) {
|
||||||
|
return "video/mp4";
|
||||||
|
} else if (lowerPath.endsWith(".webm")) {
|
||||||
|
return "video/webm";
|
||||||
|
} else if (lowerPath.endsWith(".webp")) {
|
||||||
|
return "image/webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
12
build/android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/main_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
BIN
build/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
build/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
build/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
build/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
build/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
BIN
build/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
8
build/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="wails_blue">#3574D4</color>
|
||||||
|
<color name="wails_blue_dark">#2C5FB8</color>
|
||||||
|
<color name="wails_background">#1B2636</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
</resources>
|
||||||
4
build/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Wails App</string>
|
||||||
|
</resources>
|
||||||
14
build/android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/wails_blue</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
|
||||||
|
<item name="colorOnPrimary">@android:color/white</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">@color/wails_background</item>
|
||||||
|
<item name="android:navigationBarColor">@color/wails_background</item>
|
||||||
|
<!-- Window background -->
|
||||||
|
<item name="android:windowBackground">@color/wails_background</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
build/android/build.gradle
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.7.3' apply false
|
||||||
|
}
|
||||||
26
build/android/gradle.properties
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/build/optimize-your-build#parallel
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
build/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
build/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
build/android/gradlew
vendored
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
93
build/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
11
build/android/main_android.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register main function to be called when the Android app initializes
|
||||||
|
// This is necessary because in c-shared build mode, main() is not automatically called
|
||||||
|
application.RegisterAndroidMain(main)
|
||||||
|
}
|
||||||
151
build/android/scripts/deps/install_deps.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Checking Android development dependencies...")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
errors := []string{}
|
||||||
|
|
||||||
|
// Check Go
|
||||||
|
if !checkCommand("go", "version") {
|
||||||
|
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Go is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ANDROID_HOME
|
||||||
|
androidHome := os.Getenv("ANDROID_HOME")
|
||||||
|
if androidHome == "" {
|
||||||
|
androidHome = os.Getenv("ANDROID_SDK_ROOT")
|
||||||
|
}
|
||||||
|
if androidHome == "" {
|
||||||
|
// Try common default locations
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
possiblePaths := []string{
|
||||||
|
filepath.Join(home, "Android", "Sdk"),
|
||||||
|
filepath.Join(home, "Library", "Android", "sdk"),
|
||||||
|
"/usr/local/share/android-sdk",
|
||||||
|
}
|
||||||
|
for _, p := range possiblePaths {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
androidHome = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if androidHome == "" {
|
||||||
|
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check adb
|
||||||
|
if !checkCommand("adb", "version") {
|
||||||
|
if androidHome != "" {
|
||||||
|
platformTools := filepath.Join(androidHome, "platform-tools")
|
||||||
|
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
|
||||||
|
} else {
|
||||||
|
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ adb is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check emulator
|
||||||
|
if !checkCommand("emulator", "-list-avds") {
|
||||||
|
if androidHome != "" {
|
||||||
|
emulatorPath := filepath.Join(androidHome, "emulator")
|
||||||
|
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
|
||||||
|
} else {
|
||||||
|
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Android Emulator is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NDK
|
||||||
|
ndkHome := os.Getenv("ANDROID_NDK_HOME")
|
||||||
|
if ndkHome == "" && androidHome != "" {
|
||||||
|
// Look for NDK in default location
|
||||||
|
ndkDir := filepath.Join(androidHome, "ndk")
|
||||||
|
if entries, err := os.ReadDir(ndkDir); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
ndkHome = filepath.Join(ndkDir, entry.Name())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ndkHome == "" {
|
||||||
|
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Java
|
||||||
|
if !checkCommand("java", "-version") {
|
||||||
|
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Java is installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for AVD (Android Virtual Device)
|
||||||
|
if checkCommand("emulator", "-list-avds") {
|
||||||
|
cmd := exec.Command("emulator", "-list-avds")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
|
||||||
|
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
|
||||||
|
} else {
|
||||||
|
fmt.Println("⚠ No Android Virtual Devices found. Create one via Android Studio > Tools > Device Manager")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
fmt.Println("❌ Missing dependencies:")
|
||||||
|
for _, err := range errors {
|
||||||
|
fmt.Printf(" - %s\n", err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Setup instructions:")
|
||||||
|
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
|
||||||
|
fmt.Println("2. Open SDK Manager and install:")
|
||||||
|
fmt.Println(" - Android SDK Platform (API 34)")
|
||||||
|
fmt.Println(" - Android SDK Build-Tools")
|
||||||
|
fmt.Println(" - Android SDK Platform-Tools")
|
||||||
|
fmt.Println(" - Android Emulator")
|
||||||
|
fmt.Println(" - NDK (Side by side)")
|
||||||
|
fmt.Println("3. Set environment variables:")
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
|
||||||
|
}
|
||||||
|
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
|
||||||
|
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ All Android development dependencies are installed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCommand(name string, args ...string) bool {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
18
build/android/settings.gradle
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "WailsApp"
|
||||||
|
include ':app'
|
||||||
9
build/appicon.icon/Assets/wails_icon_vector.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-246,-251)">
|
||||||
|
<g id="Ebene1">
|
||||||
|
<path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 698 B |
51
build/appicon.icon/icon.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "extended-gray:1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "tinted",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image-name" : "wails_icon_vector.svg",
|
||||||
|
"name" : "wails_icon_vector",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.25,
|
||||||
|
"translation-in-points" : [
|
||||||
|
36.890625,
|
||||||
|
4.96875
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"specular" : true,
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 35 KiB |
80
build/config.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# This file contains the configuration for this project.
|
||||||
|
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
|
||||||
|
# Note that this will overwrite any changes you have made to the assets.
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
# This information is used to generate the build assets.
|
||||||
|
info:
|
||||||
|
companyName: "My Company" # The name of the company
|
||||||
|
productName: "My Product" # The name of the application
|
||||||
|
productIdentifier: "com.mycompany.myproduct" # The unique product identifier
|
||||||
|
description: "A program that does X" # The application description
|
||||||
|
copyright: "(c) 2025, My Company" # Copyright text
|
||||||
|
comments: "Some Product Comments" # Comments
|
||||||
|
version: "0.0.1" # The application version
|
||||||
|
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
|
||||||
|
# # Should match the name of your .icon file without the extension
|
||||||
|
# # If not set and Assets.car exists, defaults to "appicon"
|
||||||
|
|
||||||
|
# iOS build configuration (uncomment to customise iOS project generation)
|
||||||
|
# Note: Keys under `ios` OVERRIDE values under `info` when set.
|
||||||
|
# ios:
|
||||||
|
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
|
||||||
|
# bundleID: "com.mycompany.myproduct"
|
||||||
|
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
|
||||||
|
# displayName: "My Product"
|
||||||
|
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
|
||||||
|
# version: "0.0.1"
|
||||||
|
# # The company/organisation name for templates and project settings
|
||||||
|
# company: "My Company"
|
||||||
|
# # Additional comments to embed in Info.plist metadata
|
||||||
|
# comments: "Some Product Comments"
|
||||||
|
|
||||||
|
# Dev mode configuration
|
||||||
|
dev_mode:
|
||||||
|
root_path: .
|
||||||
|
log_level: warn
|
||||||
|
debounce: 1000
|
||||||
|
ignore:
|
||||||
|
dir:
|
||||||
|
- .git
|
||||||
|
- node_modules
|
||||||
|
- frontend
|
||||||
|
- bin
|
||||||
|
file:
|
||||||
|
- .DS_Store
|
||||||
|
- .gitignore
|
||||||
|
- .gitkeep
|
||||||
|
watched_extension:
|
||||||
|
- "*.go"
|
||||||
|
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
|
||||||
|
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
|
||||||
|
git_ignore: true
|
||||||
|
executes:
|
||||||
|
- cmd: wails3 task common:install:frontend:deps
|
||||||
|
type: once
|
||||||
|
- cmd: wails3 task common:dev:frontend
|
||||||
|
type: background
|
||||||
|
- cmd: wails3 task build
|
||||||
|
type: blocking
|
||||||
|
- cmd: wails3 task run
|
||||||
|
type: primary
|
||||||
|
|
||||||
|
# File Associations
|
||||||
|
# More information at: https://v3.wails.io/noit/done/yet
|
||||||
|
fileAssociations:
|
||||||
|
# - ext: wails
|
||||||
|
# name: Wails
|
||||||
|
# description: Wails Application File
|
||||||
|
# iconName: wailsFileIcon
|
||||||
|
# role: Editor
|
||||||
|
# - ext: jpg
|
||||||
|
# name: JPEG
|
||||||
|
# description: Image File
|
||||||
|
# iconName: jpegFileIcon
|
||||||
|
# role: Editor
|
||||||
|
# mimeType: image/jpeg # (optional)
|
||||||
|
|
||||||
|
# Other data
|
||||||
|
other:
|
||||||
|
- name: My Other Data
|
||||||
BIN
build/darwin/Assets.car
Normal file
34
build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>U-Desk</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>u-desk.exe</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.example.udesk</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>This is a comment</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>icons</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>appicon</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.15.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026, My Company</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
29
build/darwin/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>U-Desk</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>u-desk.exe</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.example.udesk</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>This is a comment</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>icons</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>appicon</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.15.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026, My Company</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
208
build/darwin/Taskfile.yml
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Signing configuration - edit these values for your project
|
||||||
|
# SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)"
|
||||||
|
# KEYCHAIN_PROFILE: "my-notarize-profile"
|
||||||
|
# ENTITLEMENTS: "build/darwin/entitlements.plist"
|
||||||
|
|
||||||
|
# Docker image for cross-compilation (used when building on non-macOS)
|
||||||
|
CROSS_IMAGE: wails-cross
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application
|
||||||
|
cmds:
|
||||||
|
- task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH}}'
|
||||||
|
DEV: '{{.DEV}}'
|
||||||
|
OUTPUT: '{{.OUTPUT}}'
|
||||||
|
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||||
|
vars:
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
|
||||||
|
build:native:
|
||||||
|
summary: Builds the application natively on macOS
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
cmds:
|
||||||
|
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
env:
|
||||||
|
GOOS: darwin
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
CGO_CFLAGS: "-mmacosx-version-min=10.15"
|
||||||
|
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:build:frontend
|
||||||
|
- task: common:generate:icons
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required for cross-compilation. Please install Docker."
|
||||||
|
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||||
|
msg: |
|
||||||
|
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||||
|
Build it first: wails3 task setup:docker
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||||
|
- mkdir -p {{.BIN_DIR}}
|
||||||
|
- mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||||
|
vars:
|
||||||
|
DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
# Mount Go module cache for faster builds
|
||||||
|
GO_CACHE_MOUNT:
|
||||||
|
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||||
|
# Extract replace directives from go.mod and create -v mounts for each
|
||||||
|
# Handles both relative (=> ../) and absolute (=> /) paths
|
||||||
|
REPLACE_MOUNTS:
|
||||||
|
sh: |
|
||||||
|
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||||
|
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||||
|
# Convert relative paths to absolute
|
||||||
|
if [ "${path#/}" = "$path" ]; then
|
||||||
|
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||||
|
fi
|
||||||
|
# Only mount if directory exists
|
||||||
|
if [ -d "$path" ]; then
|
||||||
|
echo "-v $path:$path:ro"
|
||||||
|
fi
|
||||||
|
done | tr '\n' ' '
|
||||||
|
|
||||||
|
build:universal:
|
||||||
|
summary: Builds darwin universal binary (arm64 + amd64)
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
cmds:
|
||||||
|
- task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
|
||||||
|
|
||||||
|
build:universal:lipo:native:
|
||||||
|
summary: Creates universal binary using native lipo (macOS)
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
|
||||||
|
build:universal:lipo:go:
|
||||||
|
summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages the application into a `.app` bundle
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: create:app:bundle
|
||||||
|
|
||||||
|
package:universal:
|
||||||
|
summary: Packages darwin universal binary (arm64 + amd64)
|
||||||
|
deps:
|
||||||
|
- task: build:universal
|
||||||
|
cmds:
|
||||||
|
- task: create:app:bundle
|
||||||
|
|
||||||
|
|
||||||
|
create:app:bundle:
|
||||||
|
summary: Creates an `.app` bundle
|
||||||
|
cmds:
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
|
||||||
|
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
|
||||||
|
- |
|
||||||
|
if [ -f build/darwin/Assets.car ]; then
|
||||||
|
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
|
||||||
|
fi
|
||||||
|
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
|
||||||
|
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
|
||||||
|
- task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
|
||||||
|
|
||||||
|
codesign:adhoc:
|
||||||
|
summary: Ad-hoc signs the app bundle (macOS only)
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
|
||||||
|
codesign:skip:
|
||||||
|
summary: Skips codesigning when cross-compiling
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
|
||||||
|
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
- |
|
||||||
|
if [ -f build/darwin/Assets.car ]; then
|
||||||
|
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
fi
|
||||||
|
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||||
|
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
|
||||||
|
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
- '"{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}"'
|
||||||
|
|
||||||
|
sign:
|
||||||
|
summary: Signs the application bundle with Developer ID
|
||||||
|
desc: |
|
||||||
|
Signs the .app bundle for distribution.
|
||||||
|
Configure SIGN_IDENTITY in the vars section at the top of this file.
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
|
||||||
|
msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:notarize:
|
||||||
|
summary: Signs and notarizes the application bundle
|
||||||
|
desc: |
|
||||||
|
Signs the .app bundle and submits it for notarization.
|
||||||
|
Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file.
|
||||||
|
|
||||||
|
Setup (one-time):
|
||||||
|
wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile"
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
|
||||||
|
msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
|
||||||
|
- sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]'
|
||||||
|
msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
|
||||||
BIN
build/darwin/icons.icns
Normal file
203
build/docker/Dockerfile.cross
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Cross-compile Wails v3 apps to any platform
|
||||||
|
#
|
||||||
|
# Darwin: Zig + macOS SDK
|
||||||
|
# Linux: Native GCC when host matches target, Zig for cross-arch
|
||||||
|
# Windows: Zig + bundled mingw
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker build -t wails-cross -f Dockerfile.cross .
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross linux amd64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross linux arm64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross windows amd64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross windows arm64
|
||||||
|
|
||||||
|
FROM golang:1.25-bookworm
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Install base tools, GCC, and GTK/WebKit dev packages
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl xz-utils nodejs npm pkg-config gcc libc6-dev \
|
||||||
|
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||||
|
libgtk-4-dev libwebkitgtk-6.0-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Zig - automatically selects correct binary for host architecture
|
||||||
|
ARG ZIG_VERSION=0.14.0
|
||||||
|
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \
|
||||||
|
| tar -xJ -C /opt \
|
||||||
|
&& ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
|
||||||
|
|
||||||
|
# Download macOS SDK (required for darwin targets)
|
||||||
|
ARG MACOS_SDK_VERSION=14.5
|
||||||
|
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
|
||||||
|
| tar -xJ -C /opt \
|
||||||
|
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
|
||||||
|
|
||||||
|
ENV MACOS_SDK_PATH=/opt/macos-sdk
|
||||||
|
|
||||||
|
# Create Zig CC wrappers for cross-compilation targets
|
||||||
|
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
|
||||||
|
|
||||||
|
# Darwin arm64
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-mmacosx-version-min=*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
|
||||||
|
|
||||||
|
# Darwin amd64
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-mmacosx-version-min=*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
|
||||||
|
|
||||||
|
# Windows amd64 - uses Zig's bundled mingw
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-Wl,*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -target x86_64-windows-gnu $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-windows-amd64
|
||||||
|
|
||||||
|
# Windows arm64 - uses Zig's bundled mingw
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-Wl,*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -target aarch64-windows-gnu $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-windows-arm64
|
||||||
|
|
||||||
|
# Build script
|
||||||
|
COPY <<'SCRIPT' /usr/local/bin/build.sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OS=${1:-darwin}
|
||||||
|
ARCH=${2:-arm64}
|
||||||
|
|
||||||
|
case "${OS}-${ARCH}" in
|
||||||
|
darwin-arm64|darwin-aarch64)
|
||||||
|
export CC=zcc-darwin-arm64
|
||||||
|
export GOARCH=arm64
|
||||||
|
export GOOS=darwin
|
||||||
|
;;
|
||||||
|
darwin-amd64|darwin-x86_64)
|
||||||
|
export CC=zcc-darwin-amd64
|
||||||
|
export GOARCH=amd64
|
||||||
|
export GOOS=darwin
|
||||||
|
;;
|
||||||
|
linux-arm64|linux-aarch64)
|
||||||
|
export CC=gcc
|
||||||
|
export GOARCH=arm64
|
||||||
|
export GOOS=linux
|
||||||
|
;;
|
||||||
|
linux-amd64|linux-x86_64)
|
||||||
|
export CC=gcc
|
||||||
|
export GOARCH=amd64
|
||||||
|
export GOOS=linux
|
||||||
|
;;
|
||||||
|
windows-arm64|windows-aarch64)
|
||||||
|
export CC=zcc-windows-arm64
|
||||||
|
export GOARCH=arm64
|
||||||
|
export GOOS=windows
|
||||||
|
;;
|
||||||
|
windows-amd64|windows-x86_64)
|
||||||
|
export CC=zcc-windows-amd64
|
||||||
|
export GOARCH=amd64
|
||||||
|
export GOOS=windows
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: <os> <arch>"
|
||||||
|
echo " os: darwin, linux, windows"
|
||||||
|
echo " arch: amd64, arm64"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export CGO_CFLAGS="-w"
|
||||||
|
|
||||||
|
# Build frontend if exists and not already built (host may have built it)
|
||||||
|
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
|
||||||
|
(cd frontend && npm install --silent && npm run build --silent)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build
|
||||||
|
APP=${APP_NAME:-$(basename $(pwd))}
|
||||||
|
mkdir -p bin
|
||||||
|
|
||||||
|
EXT=""
|
||||||
|
LDFLAGS="-s -w"
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
EXT=".exe"
|
||||||
|
LDFLAGS="-s -w -H windowsgui"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAGS="production"
|
||||||
|
if [ -n "$EXTRA_TAGS" ]; then
|
||||||
|
TAGS="${TAGS},${EXTRA_TAGS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
|
||||||
|
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
|
||||||
|
SCRIPT
|
||||||
|
RUN chmod +x /usr/local/bin/build.sh
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENTRYPOINT ["/usr/local/bin/build.sh"]
|
||||||
|
CMD ["darwin", "arm64"]
|
||||||
41
build/docker/Dockerfile.server
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Wails Server Mode Dockerfile
|
||||||
|
# Multi-stage build for minimal image size
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Remove local replace directive if present (for production builds)
|
||||||
|
RUN sed -i '/^replace/d' go.mod || true
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod tidy
|
||||||
|
|
||||||
|
# Build the server binary
|
||||||
|
RUN go build -tags server -ldflags="-s -w" -o server .
|
||||||
|
|
||||||
|
# Runtime stage - minimal image
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
|
||||||
|
# Copy the binary
|
||||||
|
COPY --from=builder /app/server /server
|
||||||
|
|
||||||
|
# Copy frontend assets
|
||||||
|
COPY --from=builder /app/frontend/dist /frontend/dist
|
||||||
|
|
||||||
|
# Expose the default port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Bind to all interfaces (required for Docker)
|
||||||
|
# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
|
||||||
|
ENV WAILS_SERVER_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
ENTRYPOINT ["/server"]
|
||||||
116
build/ios/Assets.xcassets
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon-20@2x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-20@3x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-29@2x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-29@3x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-40@2x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-40@3x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-60@2x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-60@3x.png",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "60x60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-20.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-20@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-29.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-29@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-40.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-40@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-76.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-76@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "76x76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-83.5@2x.png",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "83.5x83.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "icon-1024.png",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
62
build/ios/Info.dev.plist
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>u-desk.exe</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.example.udesk.dev</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>U-Desk (Dev)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>U-Desk (Dev)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0-dev</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>15.0</string>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<!-- Development mode enabled -->
|
||||||
|
<key>WailsDevelopmentMode</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026, My Company</string>
|
||||||
|
|
||||||
|
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>This is a comment</string>
|
||||||
|
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
59
build/ios/Info.plist
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>u-desk.exe</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.example.udesk</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>U-Desk</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>U-Desk</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>15.0</string>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026, My Company</string>
|
||||||
|
|
||||||
|
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>This is a comment</string>
|
||||||
|
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
53
build/ios/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="U-Desk" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
|
||||||
|
<rect key="frame" x="0.0" y="397" width="393" height="43"/>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A u-desk application" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
|
||||||
|
<rect key="frame" x="0.0" y="448" width="393" height="21"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
|
||||||
|
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/2" constant="-20" id="moa-c2-u7t"/>
|
||||||
|
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
|
||||||
|
|
||||||
|
<constraint firstItem="MN2-I3-ftu" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" symbolic="YES" id="cPy-rs-vsC"/>
|
||||||
|
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="OQL-iM-xY6"/>
|
||||||
|
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="Dti-5h-tvW"/>
|
||||||
|
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
293
build/ios/Taskfile.yml
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
BUNDLE_ID: '{{.BUNDLE_ID | default "com.wails.app"}}'
|
||||||
|
# SDK_PATH is computed lazily at task-level to avoid errors on non-macOS systems
|
||||||
|
# Each task that needs it defines SDK_PATH in its own vars section
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
install:deps:
|
||||||
|
summary: Check and install iOS development dependencies
|
||||||
|
cmds:
|
||||||
|
- go run build/ios/scripts/deps/install_deps.go
|
||||||
|
env:
|
||||||
|
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
|
||||||
|
prompt: This will check and install iOS development dependencies. Continue?
|
||||||
|
|
||||||
|
# Note: Bindings generation may show CGO warnings for iOS C imports.
|
||||||
|
# These warnings are harmless and don't affect the generated bindings,
|
||||||
|
# as the generator only needs to parse Go types, not C implementations.
|
||||||
|
build:
|
||||||
|
summary: Creates a build of the application for iOS
|
||||||
|
deps:
|
||||||
|
- task: generate:ios:overlay
|
||||||
|
- task: generate:ios:xcode
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: generate:ios:bindings
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
PRODUCTION:
|
||||||
|
ref: .PRODUCTION
|
||||||
|
- task: common:generate:icons
|
||||||
|
cmds:
|
||||||
|
- echo "Building iOS app {{.APP_NAME}}..."
|
||||||
|
- go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json {{.BUILD_FLAGS}} -o {{.OUTPUT}}.a
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,ios -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags ios,debug -buildvcs=false -gcflags=all="-l"{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
SDK_PATH:
|
||||||
|
sh: xcrun --sdk iphonesimulator --show-sdk-path
|
||||||
|
env:
|
||||||
|
GOOS: ios
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
PRODUCTION: '{{.PRODUCTION | default "false"}}'
|
||||||
|
CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0'
|
||||||
|
CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator'
|
||||||
|
|
||||||
|
compile:objc:
|
||||||
|
summary: Compile Objective-C iOS wrapper
|
||||||
|
vars:
|
||||||
|
SDK_PATH:
|
||||||
|
sh: xcrun --sdk iphonesimulator --show-sdk-path
|
||||||
|
cmds:
|
||||||
|
- xcrun -sdk iphonesimulator clang -target arm64-apple-ios15.0-simulator -isysroot {{.SDK_PATH}} -framework Foundation -framework UIKit -framework WebKit -o {{.BIN_DIR}}/{{.APP_NAME}} build/ios/main.m
|
||||||
|
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}"
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application into a `.app` bundle
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
PRODUCTION: "true"
|
||||||
|
cmds:
|
||||||
|
- task: create:app:bundle
|
||||||
|
|
||||||
|
create:app:bundle:
|
||||||
|
summary: Creates an iOS `.app` bundle
|
||||||
|
cmds:
|
||||||
|
- rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
|
||||||
|
- cp build/ios/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
|
||||||
|
- |
|
||||||
|
# Compile asset catalog and embed icons in the app bundle
|
||||||
|
APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
AC_IN="build/ios/xcode/main/Assets.xcassets"
|
||||||
|
if [ -d "$AC_IN" ]; then
|
||||||
|
TMP_AC=$(mktemp -d)
|
||||||
|
xcrun actool \
|
||||||
|
--compile "$TMP_AC" \
|
||||||
|
--app-icon AppIcon \
|
||||||
|
--platform iphonesimulator \
|
||||||
|
--minimum-deployment-target 15.0 \
|
||||||
|
--product-type com.apple.product-type.application \
|
||||||
|
--target-device iphone \
|
||||||
|
--target-device ipad \
|
||||||
|
--output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
|
||||||
|
"$AC_IN"
|
||||||
|
if [ -f "$TMP_AC/Assets.car" ]; then
|
||||||
|
cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
|
||||||
|
fi
|
||||||
|
rm -rf "$TMP_AC"
|
||||||
|
if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
|
||||||
|
deploy-simulator:
|
||||||
|
summary: Deploy to iOS Simulator
|
||||||
|
deps: [package]
|
||||||
|
cmds:
|
||||||
|
- xcrun simctl terminate booted {{.BUNDLE_ID}} 2>/dev/null || true
|
||||||
|
- xcrun simctl uninstall booted {{.BUNDLE_ID}} 2>/dev/null || true
|
||||||
|
- xcrun simctl install booted "{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
- xcrun simctl launch booted {{.BUNDLE_ID}}
|
||||||
|
|
||||||
|
compile:ios:
|
||||||
|
summary: Compile the iOS executable from Go archive and main.m
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
SDK_PATH:
|
||||||
|
sh: xcrun --sdk iphonesimulator --show-sdk-path
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
MAIN_M=build/ios/xcode/main/main.m
|
||||||
|
if [ ! -f "$MAIN_M" ]; then
|
||||||
|
MAIN_M=build/ios/main.m
|
||||||
|
fi
|
||||||
|
xcrun -sdk iphonesimulator clang \
|
||||||
|
-target arm64-apple-ios15.0-simulator \
|
||||||
|
-isysroot {{.SDK_PATH}} \
|
||||||
|
-framework Foundation -framework UIKit -framework WebKit \
|
||||||
|
-framework Security -framework CoreFoundation \
|
||||||
|
-lresolv \
|
||||||
|
-o "{{.BIN_DIR}}/{{.APP_NAME | lower}}" \
|
||||||
|
"$MAIN_M" "{{.BIN_DIR}}/{{.APP_NAME}}.a"
|
||||||
|
|
||||||
|
generate:ios:bindings:
|
||||||
|
internal: true
|
||||||
|
summary: Generates bindings for iOS with proper CGO flags
|
||||||
|
sources:
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
vars:
|
||||||
|
SDK_PATH:
|
||||||
|
sh: xcrun --sdk iphonesimulator --show-sdk-path
|
||||||
|
cmds:
|
||||||
|
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
|
||||||
|
env:
|
||||||
|
GOOS: ios
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default "arm64"}}'
|
||||||
|
CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0'
|
||||||
|
CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target arm64-apple-ios15.0-simulator'
|
||||||
|
|
||||||
|
ensure-simulator:
|
||||||
|
internal: true
|
||||||
|
summary: Ensure iOS Simulator is running and booted
|
||||||
|
silent: true
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
if ! xcrun simctl list devices booted | grep -q "Booted"; then
|
||||||
|
echo "Starting iOS Simulator..."
|
||||||
|
# Get first available iPhone device
|
||||||
|
DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o "[A-F0-9-]\{36\}" || true)
|
||||||
|
if [ -z "$DEVICE_ID" ]; then
|
||||||
|
echo "No iPhone simulator found. Creating one..."
|
||||||
|
RUNTIME=$(xcrun simctl list runtimes | grep iOS | tail -1 | awk '{print $NF}')
|
||||||
|
DEVICE_ID=$(xcrun simctl create "iPhone 15 Pro" "iPhone 15 Pro" "$RUNTIME")
|
||||||
|
fi
|
||||||
|
# Boot the device
|
||||||
|
echo "Booting device $DEVICE_ID..."
|
||||||
|
xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true
|
||||||
|
# Open Simulator app
|
||||||
|
open -a Simulator
|
||||||
|
# Wait for boot (max 30 seconds)
|
||||||
|
for i in {1..30}; do
|
||||||
|
if xcrun simctl list devices booted | grep -q "Booted"; then
|
||||||
|
echo "Simulator booted successfully"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
# Final check
|
||||||
|
if ! xcrun simctl list devices booted | grep -q "Booted"; then
|
||||||
|
echo "Failed to boot simulator after 30 seconds"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
preconditions:
|
||||||
|
- sh: command -v xcrun
|
||||||
|
msg: "xcrun not found. Please run 'wails3 task ios:install:deps' to install iOS development dependencies"
|
||||||
|
|
||||||
|
generate:ios:overlay:
|
||||||
|
internal: true
|
||||||
|
summary: Generate Go build overlay and iOS shim
|
||||||
|
sources:
|
||||||
|
- build/config.yml
|
||||||
|
generates:
|
||||||
|
- build/ios/xcode/overlay.json
|
||||||
|
- build/ios/xcode/gen/main_ios.gen.go
|
||||||
|
cmds:
|
||||||
|
- wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml
|
||||||
|
|
||||||
|
generate:ios:xcode:
|
||||||
|
internal: true
|
||||||
|
summary: Generate iOS Xcode project structure and assets
|
||||||
|
sources:
|
||||||
|
- build/config.yml
|
||||||
|
- build/appicon.png
|
||||||
|
generates:
|
||||||
|
- build/ios/xcode/main/main.m
|
||||||
|
- build/ios/xcode/main/Assets.xcassets/**/*
|
||||||
|
- build/ios/xcode/project.pbxproj
|
||||||
|
cmds:
|
||||||
|
- wails3 ios xcode:gen -outdir build/ios/xcode -config build/config.yml
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Run the application in iOS Simulator
|
||||||
|
deps:
|
||||||
|
- task: ensure-simulator
|
||||||
|
- task: compile:ios
|
||||||
|
cmds:
|
||||||
|
- rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
- cp "{{.BIN_DIR}}/{{.APP_NAME | lower}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/{{.APP_NAME | lower}}"
|
||||||
|
- cp build/ios/Info.dev.plist "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Info.plist"
|
||||||
|
- |
|
||||||
|
# Compile asset catalog and embed icons for dev bundle
|
||||||
|
APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
AC_IN="build/ios/xcode/main/Assets.xcassets"
|
||||||
|
if [ -d "$AC_IN" ]; then
|
||||||
|
TMP_AC=$(mktemp -d)
|
||||||
|
xcrun actool \
|
||||||
|
--compile "$TMP_AC" \
|
||||||
|
--app-icon AppIcon \
|
||||||
|
--platform iphonesimulator \
|
||||||
|
--minimum-deployment-target 15.0 \
|
||||||
|
--product-type com.apple.product-type.application \
|
||||||
|
--target-device iphone \
|
||||||
|
--target-device ipad \
|
||||||
|
--output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
|
||||||
|
"$AC_IN"
|
||||||
|
if [ -f "$TMP_AC/Assets.car" ]; then
|
||||||
|
cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
|
||||||
|
fi
|
||||||
|
rm -rf "$TMP_AC"
|
||||||
|
if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
|
||||||
|
/usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
- xcrun simctl terminate booted "com.wails.{{.APP_NAME | lower}}.dev" 2>/dev/null || true
|
||||||
|
- xcrun simctl uninstall booted "com.wails.{{.APP_NAME | lower}}.dev" 2>/dev/null || true
|
||||||
|
- xcrun simctl install booted "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
- xcrun simctl launch booted "com.wails.{{.APP_NAME | lower}}.dev"
|
||||||
|
|
||||||
|
xcode:
|
||||||
|
summary: Open the generated Xcode project for this app
|
||||||
|
cmds:
|
||||||
|
- task: generate:ios:xcode
|
||||||
|
- open build/ios/xcode/main.xcodeproj
|
||||||
|
|
||||||
|
logs:
|
||||||
|
summary: Stream iOS Simulator logs filtered to this app
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
xcrun simctl spawn booted log stream \
|
||||||
|
--level debug \
|
||||||
|
--style compact \
|
||||||
|
--predicate 'senderImagePath CONTAINS[c] "{{.APP_NAME | lower}}.app/" OR composedMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR eventMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR process == "{{.APP_NAME | lower}}" OR category CONTAINS[c] "{{.APP_NAME | lower}}"'
|
||||||
|
|
||||||
|
logs:dev:
|
||||||
|
summary: Stream logs for the dev bundle (used by `task ios:run`)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
xcrun simctl spawn booted log stream \
|
||||||
|
--level debug \
|
||||||
|
--style compact \
|
||||||
|
--predicate 'senderImagePath CONTAINS[c] ".dev.app/" OR subsystem == "com.wails.{{.APP_NAME | lower}}.dev" OR process == "{{.APP_NAME | lower}}"'
|
||||||
|
|
||||||
|
logs:wide:
|
||||||
|
summary: Wide log stream to help discover the exact process/bundle identifiers
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
xcrun simctl spawn booted log stream \
|
||||||
|
--level debug \
|
||||||
|
--style compact \
|
||||||
|
--predicate 'senderImagePath CONTAINS[c] ".app/"'
|
||||||
10
build/ios/app_options_default.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
|
// modifyOptionsForIOS is a no-op on non-iOS platforms
|
||||||
|
func modifyOptionsForIOS(opts *application.Options) {
|
||||||
|
// No modifications needed for non-iOS platforms
|
||||||
|
}
|
||||||
11
build/ios/app_options_ios.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
|
// modifyOptionsForIOS adjusts the application options for iOS
|
||||||
|
func modifyOptionsForIOS(opts *application.Options) {
|
||||||
|
// Disable signal handlers on iOS to prevent crashes
|
||||||
|
opts.DisableDefaultSignalHandler = true
|
||||||
|
}
|
||||||
72
build/ios/build.sh
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Build configuration
|
||||||
|
APP_NAME="u-desk.exe"
|
||||||
|
BUNDLE_ID="com.example.udesk"
|
||||||
|
VERSION="0.1.0"
|
||||||
|
BUILD_NUMBER="0.1.0"
|
||||||
|
BUILD_DIR="build/ios"
|
||||||
|
TARGET="simulator"
|
||||||
|
|
||||||
|
echo "Building iOS app: $APP_NAME"
|
||||||
|
echo "Bundle ID: $BUNDLE_ID"
|
||||||
|
echo "Version: $VERSION ($BUILD_NUMBER)"
|
||||||
|
echo "Target: $TARGET"
|
||||||
|
|
||||||
|
# Ensure build directory exists
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# Determine SDK and target architecture
|
||||||
|
if [ "$TARGET" = "simulator" ]; then
|
||||||
|
SDK="iphonesimulator"
|
||||||
|
ARCH="arm64-apple-ios15.0-simulator"
|
||||||
|
elif [ "$TARGET" = "device" ]; then
|
||||||
|
SDK="iphoneos"
|
||||||
|
ARCH="arm64-apple-ios15.0"
|
||||||
|
else
|
||||||
|
echo "Unknown target: $TARGET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get SDK path
|
||||||
|
SDK_PATH=$(xcrun --sdk $SDK --show-sdk-path)
|
||||||
|
|
||||||
|
# Compile the application
|
||||||
|
echo "Compiling with SDK: $SDK"
|
||||||
|
xcrun -sdk $SDK clang \
|
||||||
|
-target $ARCH \
|
||||||
|
-isysroot "$SDK_PATH" \
|
||||||
|
-framework Foundation \
|
||||||
|
-framework UIKit \
|
||||||
|
-framework WebKit \
|
||||||
|
-framework CoreGraphics \
|
||||||
|
-o "$BUILD_DIR/$APP_NAME" \
|
||||||
|
"$BUILD_DIR/main.m"
|
||||||
|
|
||||||
|
# Create app bundle
|
||||||
|
echo "Creating app bundle..."
|
||||||
|
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$APP_BUNDLE"
|
||||||
|
|
||||||
|
# Move executable
|
||||||
|
mv "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/"
|
||||||
|
|
||||||
|
# Copy Info.plist
|
||||||
|
cp "$BUILD_DIR/Info.plist" "$APP_BUNDLE/"
|
||||||
|
|
||||||
|
# Sign the app
|
||||||
|
echo "Signing app..."
|
||||||
|
codesign --force --sign - "$APP_BUNDLE"
|
||||||
|
|
||||||
|
echo "Build complete: $APP_BUNDLE"
|
||||||
|
|
||||||
|
# Deploy to simulator if requested
|
||||||
|
if [ "$TARGET" = "simulator" ]; then
|
||||||
|
echo "Deploying to simulator..."
|
||||||
|
xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
|
||||||
|
xcrun simctl install booted "$APP_BUNDLE"
|
||||||
|
xcrun simctl launch booted "$BUNDLE_ID"
|
||||||
|
echo "App launched on simulator"
|
||||||
|
fi
|
||||||
21
build/ios/entitlements.plist
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Development entitlements -->
|
||||||
|
<key>get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- App Sandbox -->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Network access -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- File access (read-only) -->
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
3
build/ios/icon.png
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# iOS Icon Placeholder
|
||||||
|
# This file should be replaced with the actual app icon (1024x1024 PNG)
|
||||||
|
# The build process will generate all required icon sizes from this base icon
|
||||||
23
build/ios/main.m
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build ios
|
||||||
|
// Minimal bootstrap: delegate comes from Go archive (WailsAppDelegate)
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
// External Go initialization function from the c-archive (declare before use)
|
||||||
|
extern void WailsIOSMain();
|
||||||
|
|
||||||
|
int main(int argc, char * argv[]) {
|
||||||
|
@autoreleasepool {
|
||||||
|
// Disable buffering so stdout/stderr from Go log.Printf flush immediately
|
||||||
|
setvbuf(stdout, NULL, _IONBF, 0);
|
||||||
|
setvbuf(stderr, NULL, _IONBF, 0);
|
||||||
|
|
||||||
|
// Start Go runtime on a background queue to avoid blocking main thread/UI
|
||||||
|
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||||
|
WailsIOSMain();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run UIApplicationMain using WailsAppDelegate provided by the Go archive
|
||||||
|
return UIApplicationMain(argc, argv, nil, @"WailsAppDelegate");
|
||||||
|
}
|
||||||
|
}
|
||||||
24
build/ios/main_ios.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"C"
|
||||||
|
)
|
||||||
|
|
||||||
|
// For iOS builds, we need to export a function that can be called from Objective-C
|
||||||
|
// This wrapper allows us to keep the original main.go unmodified
|
||||||
|
|
||||||
|
//export WailsIOSMain
|
||||||
|
func WailsIOSMain() {
|
||||||
|
// DO NOT lock the goroutine to the current OS thread on iOS!
|
||||||
|
// This causes signal handling issues:
|
||||||
|
// "signal 16 received on thread with no signal stack"
|
||||||
|
// "fatal error: non-Go code disabled sigaltstack"
|
||||||
|
// iOS apps run in a sandboxed environment where the Go runtime's
|
||||||
|
// signal handling doesn't work the same way as desktop platforms.
|
||||||
|
|
||||||
|
// Call the actual main function from main.go
|
||||||
|
// This ensures all the user's code is executed
|
||||||
|
main()
|
||||||
|
}
|
||||||
222
build/ios/project.pbxproj
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; };
|
||||||
|
C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; };
|
||||||
|
C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; };
|
||||||
|
C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; };
|
||||||
|
C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; };
|
||||||
|
C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; };
|
||||||
|
C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; };
|
||||||
|
C0DEBEEF00000000000000F7 /* U-Desk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* U-Desk.a */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
|
||||||
|
C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
C0DEBEEF0000000000000004 /* U-Desk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "U-Desk.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
|
C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
|
||||||
|
C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||||
|
C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||||
|
C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
||||||
|
C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
|
||||||
|
C0DEBEEF0000000000000107 /* U-Desk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "U-Desk.a"; path = ../../../bin/U-Desk.a; sourceTree = SOURCE_ROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
C0DEBEEF0000000000000010 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0DEBEEF0000000000000020 /* Products */,
|
||||||
|
C0DEBEEF0000000000000045 /* Frameworks */,
|
||||||
|
C0DEBEEF0000000000000030 /* main */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0DEBEEF0000000000000020 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0DEBEEF0000000000000004 /* U-Desk.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0DEBEEF0000000000000030 /* main */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0DEBEEF0000000000000002 /* main.m */,
|
||||||
|
C0DEBEEF0000000000000003 /* Info.plist */,
|
||||||
|
);
|
||||||
|
path = main;
|
||||||
|
sourceTree = SOURCE_ROOT;
|
||||||
|
};
|
||||||
|
C0DEBEEF0000000000000045 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0DEBEEF0000000000000101 /* UIKit.framework */,
|
||||||
|
C0DEBEEF0000000000000102 /* Foundation.framework */,
|
||||||
|
C0DEBEEF0000000000000103 /* WebKit.framework */,
|
||||||
|
C0DEBEEF0000000000000104 /* Security.framework */,
|
||||||
|
C0DEBEEF0000000000000105 /* CoreFoundation.framework */,
|
||||||
|
C0DEBEEF0000000000000106 /* libresolv.tbd */,
|
||||||
|
C0DEBEEF0000000000000107 /* U-Desk.a */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
C0DEBEEF0000000000000040 /* U-Desk */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "U-Desk" */;
|
||||||
|
buildPhases = (
|
||||||
|
C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */,
|
||||||
|
C0DEBEEF0000000000000050 /* Sources */,
|
||||||
|
C0DEBEEF0000000000000056 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = "U-Desk";
|
||||||
|
productName = "U-Desk";
|
||||||
|
productReference = C0DEBEEF0000000000000004 /* U-Desk.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
C0DEBEEF0000000000000060 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
ORGANIZATIONNAME = "My Company";
|
||||||
|
TargetAttributes = {
|
||||||
|
C0DEBEEF0000000000000040 = {
|
||||||
|
CreatedOnToolsVersion = 15.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */;
|
||||||
|
compatibilityVersion = "Xcode 15.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = C0DEBEEF0000000000000010;
|
||||||
|
productRefGroup = C0DEBEEF0000000000000020 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
C0DEBEEF0000000000000040 /* U-Desk */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
C0DEBEEF0000000000000056 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C0DEBEEF00000000000000F7 /* U-Desk.a in Frameworks */,
|
||||||
|
C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */,
|
||||||
|
C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */,
|
||||||
|
C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */,
|
||||||
|
C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */,
|
||||||
|
C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */,
|
||||||
|
C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Prebuild: Wails Go Archive";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nSDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path)\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Build Go c-archive if missing or older than sources\nif [ ! -f bin/U-Desk.a ]; then\n echo \"Building Go c-archive...\"\n go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o bin/U-Desk.a\nfi\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
C0DEBEEF0000000000000050 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C0DEBEEF0000000000000001 /* main.m in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
C0DEBEEF0000000000000090 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
INFOPLIST_FILE = main/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.example.udesk";
|
||||||
|
PRODUCT_NAME = "U-Desk";
|
||||||
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
|
SDKROOT = iphonesimulator;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
C0DEBEEF00000000000000A0 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
INFOPLIST_FILE = main/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.example.udesk";
|
||||||
|
PRODUCT_NAME = "U-Desk";
|
||||||
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
|
SDKROOT = iphonesimulator;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "U-Desk" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C0DEBEEF0000000000000090 /* Debug */,
|
||||||
|
C0DEBEEF00000000000000A0 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C0DEBEEF0000000000000090 /* Debug */,
|
||||||
|
C0DEBEEF00000000000000A0 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = C0DEBEEF0000000000000060 /* Project object */;
|
||||||
|
}
|
||||||
319
build/ios/scripts/deps/install_deps.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
// install_deps.go - iOS development dependency checker
|
||||||
|
// This script checks for required iOS development tools.
|
||||||
|
// It's designed to be portable across different shells by using Go instead of shell scripts.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// go run install_deps.go # Interactive mode
|
||||||
|
// TASK_FORCE_YES=true go run install_deps.go # Auto-accept prompts
|
||||||
|
// CI=true go run install_deps.go # CI mode (auto-accept)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dependency struct {
|
||||||
|
Name string
|
||||||
|
CheckFunc func() (bool, string) // Returns (success, details)
|
||||||
|
Required bool
|
||||||
|
InstallCmd []string
|
||||||
|
InstallMsg string
|
||||||
|
SuccessMsg string
|
||||||
|
FailureMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Checking iOS development dependencies...")
|
||||||
|
fmt.Println("=" + strings.Repeat("=", 50))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
hasErrors := false
|
||||||
|
dependencies := []Dependency{
|
||||||
|
{
|
||||||
|
Name: "Xcode",
|
||||||
|
CheckFunc: func() (bool, string) {
|
||||||
|
// Check if xcodebuild exists
|
||||||
|
if !checkCommand([]string{"xcodebuild", "-version"}) {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
// Get version info
|
||||||
|
out, err := exec.Command("xcodebuild", "-version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return true, strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
},
|
||||||
|
Required: true,
|
||||||
|
InstallMsg: "Please install Xcode from the Mac App Store:\n https://apps.apple.com/app/xcode/id497799835\n Xcode is REQUIRED for iOS development (includes iOS SDKs, simulators, and frameworks)",
|
||||||
|
SuccessMsg: "✅ Xcode found",
|
||||||
|
FailureMsg: "❌ Xcode not found (REQUIRED)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Xcode Developer Path",
|
||||||
|
CheckFunc: func() (bool, string) {
|
||||||
|
// Check if xcode-select points to a valid Xcode path
|
||||||
|
out, err := exec.Command("xcode-select", "-p").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, "xcode-select not configured"
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
// Check if path exists and is in Xcode.app
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return false, "Invalid Xcode path"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's pointing to Xcode.app (not just Command Line Tools)
|
||||||
|
if !strings.Contains(path, "Xcode.app") {
|
||||||
|
return false, fmt.Sprintf("Points to %s (should be Xcode.app)", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, path
|
||||||
|
},
|
||||||
|
Required: true,
|
||||||
|
InstallCmd: []string{"sudo", "xcode-select", "-s", "/Applications/Xcode.app/Contents/Developer"},
|
||||||
|
InstallMsg: "Xcode developer path needs to be configured",
|
||||||
|
SuccessMsg: "✅ Xcode developer path configured",
|
||||||
|
FailureMsg: "❌ Xcode developer path not configured correctly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "iOS SDK",
|
||||||
|
CheckFunc: func() (bool, string) {
|
||||||
|
// Get the iOS Simulator SDK path
|
||||||
|
cmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, "Cannot find iOS SDK"
|
||||||
|
}
|
||||||
|
sdkPath := strings.TrimSpace(string(output))
|
||||||
|
|
||||||
|
// Check if the SDK path exists
|
||||||
|
if _, err := os.Stat(sdkPath); err != nil {
|
||||||
|
return false, "iOS SDK path not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for UIKit framework (essential for iOS development)
|
||||||
|
uikitPath := fmt.Sprintf("%s/System/Library/Frameworks/UIKit.framework", sdkPath)
|
||||||
|
if _, err := os.Stat(uikitPath); err != nil {
|
||||||
|
return false, "UIKit.framework not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get SDK version
|
||||||
|
versionCmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-version")
|
||||||
|
versionOut, _ := versionCmd.Output()
|
||||||
|
version := strings.TrimSpace(string(versionOut))
|
||||||
|
|
||||||
|
return true, fmt.Sprintf("iOS %s SDK", version)
|
||||||
|
},
|
||||||
|
Required: true,
|
||||||
|
InstallMsg: "iOS SDK comes with Xcode. Please ensure Xcode is properly installed.",
|
||||||
|
SuccessMsg: "✅ iOS SDK found with UIKit framework",
|
||||||
|
FailureMsg: "❌ iOS SDK not found or incomplete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "iOS Simulator Runtime",
|
||||||
|
CheckFunc: func() (bool, string) {
|
||||||
|
if !checkCommand([]string{"xcrun", "simctl", "help"}) {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
// Check if we can list runtimes
|
||||||
|
out, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, "Cannot access simulator"
|
||||||
|
}
|
||||||
|
// Count iOS runtimes
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
count := 0
|
||||||
|
var versions []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
|
||||||
|
count++
|
||||||
|
// Extract version number
|
||||||
|
if parts := strings.Fields(line); len(parts) > 2 {
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
|
||||||
|
versions = append(versions, strings.Trim(part, "()"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return true, fmt.Sprintf("%d runtime(s): %s", count, strings.Join(versions, ", "))
|
||||||
|
}
|
||||||
|
return false, "No iOS runtimes installed"
|
||||||
|
},
|
||||||
|
Required: true,
|
||||||
|
InstallMsg: "iOS Simulator runtimes come with Xcode. You may need to download them:\n Xcode → Settings → Platforms → iOS",
|
||||||
|
SuccessMsg: "✅ iOS Simulator runtime available",
|
||||||
|
FailureMsg: "❌ iOS Simulator runtime not available",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each dependency
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
success, details := dep.CheckFunc()
|
||||||
|
if success {
|
||||||
|
msg := dep.SuccessMsg
|
||||||
|
if details != "" {
|
||||||
|
msg = fmt.Sprintf("%s (%s)", dep.SuccessMsg, details)
|
||||||
|
}
|
||||||
|
fmt.Println(msg)
|
||||||
|
} else {
|
||||||
|
fmt.Println(dep.FailureMsg)
|
||||||
|
if details != "" {
|
||||||
|
fmt.Printf(" Details: %s\n", details)
|
||||||
|
}
|
||||||
|
if dep.Required {
|
||||||
|
hasErrors = true
|
||||||
|
if len(dep.InstallCmd) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" " + dep.InstallMsg)
|
||||||
|
fmt.Printf(" Fix command: %s\n", strings.Join(dep.InstallCmd, " "))
|
||||||
|
if promptUser("Do you want to run this command?") {
|
||||||
|
fmt.Println("Running command...")
|
||||||
|
cmd := exec.Command(dep.InstallCmd[0], dep.InstallCmd[1:]...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Printf("Command failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("✅ Command completed. Please run this check again.")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Please run manually: %s\n", strings.Join(dep.InstallCmd, " "))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println(" " + dep.InstallMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for iPhone simulators
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Checking for iPhone simulator devices...")
|
||||||
|
if !checkCommand([]string{"xcrun", "simctl", "list", "devices"}) {
|
||||||
|
fmt.Println("❌ Cannot check for iPhone simulators")
|
||||||
|
hasErrors = true
|
||||||
|
} else {
|
||||||
|
out, err := exec.Command("xcrun", "simctl", "list", "devices").Output()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("❌ Failed to list simulator devices")
|
||||||
|
hasErrors = true
|
||||||
|
} else if !strings.Contains(string(out), "iPhone") {
|
||||||
|
fmt.Println("⚠️ No iPhone simulator devices found")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Get the latest iOS runtime
|
||||||
|
runtimeOut, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" Failed to get iOS runtimes:", err)
|
||||||
|
} else {
|
||||||
|
lines := strings.Split(string(runtimeOut), "\n")
|
||||||
|
var latestRuntime string
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
|
||||||
|
// Extract runtime identifier
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
latestRuntime = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestRuntime == "" {
|
||||||
|
fmt.Println(" No iOS runtime found. Please install iOS simulators in Xcode:")
|
||||||
|
fmt.Println(" Xcode → Settings → Platforms → iOS")
|
||||||
|
} else {
|
||||||
|
fmt.Println(" Would you like to create an iPhone 15 Pro simulator?")
|
||||||
|
createCmd := []string{"xcrun", "simctl", "create", "iPhone 15 Pro", "iPhone 15 Pro", latestRuntime}
|
||||||
|
fmt.Printf(" Command: %s\n", strings.Join(createCmd, " "))
|
||||||
|
if promptUser("Create simulator?") {
|
||||||
|
cmd := exec.Command(createCmd[0], createCmd[1:]...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Printf(" Failed to create simulator: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(" ✅ iPhone 15 Pro simulator created")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println(" Skipping simulator creation")
|
||||||
|
fmt.Printf(" Create manually: %s\n", strings.Join(createCmd, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Count iPhone devices
|
||||||
|
count := 0
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "iPhone") && !strings.Contains(line, "unavailable") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("✅ %d iPhone simulator device(s) available\n", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final summary
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("=" + strings.Repeat("=", 50))
|
||||||
|
if hasErrors {
|
||||||
|
fmt.Println("❌ Some required dependencies are missing or misconfigured.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Quick setup guide:")
|
||||||
|
fmt.Println("1. Install Xcode from Mac App Store (if not installed)")
|
||||||
|
fmt.Println("2. Open Xcode once and agree to the license")
|
||||||
|
fmt.Println("3. Install additional components when prompted")
|
||||||
|
fmt.Println("4. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer")
|
||||||
|
fmt.Println("5. Download iOS simulators: Xcode → Settings → Platforms → iOS")
|
||||||
|
fmt.Println("6. Run this check again")
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ All required dependencies are installed!")
|
||||||
|
fmt.Println(" You're ready for iOS development with Wails!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCommand(args []string) bool {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
err := cmd.Run()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptUser(question string) bool {
|
||||||
|
// Check if we're in a non-interactive environment
|
||||||
|
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
|
||||||
|
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
fmt.Printf("%s [y/N]: ", question)
|
||||||
|
|
||||||
|
response, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
response = strings.ToLower(strings.TrimSpace(response))
|
||||||
|
return response == "y" || response == "yes"
|
||||||
|
}
|
||||||
226
build/linux/Taskfile.yml
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Signing configuration - edit these values for your project
|
||||||
|
# PGP_KEY: "path/to/signing-key.asc"
|
||||||
|
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
|
||||||
|
#
|
||||||
|
# Password is stored securely in system keychain. Run: wails3 setup signing
|
||||||
|
|
||||||
|
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
|
||||||
|
CROSS_IMAGE: wails-cross
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application for Linux
|
||||||
|
cmds:
|
||||||
|
# Linux requires CGO - use Docker when:
|
||||||
|
# 1. Cross-compiling from non-Linux, OR
|
||||||
|
# 2. No C compiler is available, OR
|
||||||
|
# 3. Target architecture differs from host architecture (cross-arch compilation)
|
||||||
|
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH}}'
|
||||||
|
DEV: '{{.DEV}}'
|
||||||
|
OUTPUT: '{{.OUTPUT}}'
|
||||||
|
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||||
|
vars:
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
# Determine target architecture (defaults to host ARCH if not specified)
|
||||||
|
TARGET_ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
# Check if a C compiler is available (gcc or clang)
|
||||||
|
HAS_CC:
|
||||||
|
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
|
||||||
|
|
||||||
|
build:native:
|
||||||
|
summary: Builds the application natively on Linux
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
cmds:
|
||||||
|
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
env:
|
||||||
|
GOOS: linux
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:build:frontend
|
||||||
|
- task: common:generate:icons
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
|
||||||
|
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||||
|
msg: |
|
||||||
|
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||||
|
Build it first: wails3 task setup:docker
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||||
|
- mkdir -p {{.BIN_DIR}}
|
||||||
|
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||||
|
vars:
|
||||||
|
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
# Mount Go module cache for faster builds
|
||||||
|
GO_CACHE_MOUNT:
|
||||||
|
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||||
|
# Extract replace directives from go.mod and create -v mounts for each
|
||||||
|
REPLACE_MOUNTS:
|
||||||
|
sh: |
|
||||||
|
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||||
|
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||||
|
# Convert relative paths to absolute
|
||||||
|
if [ "${path#/}" = "$path" ]; then
|
||||||
|
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||||
|
fi
|
||||||
|
# Only mount if directory exists
|
||||||
|
if [ -d "$path" ]; then
|
||||||
|
echo "-v $path:$path:ro"
|
||||||
|
fi
|
||||||
|
done | tr '\n' ' '
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages the application for Linux
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: create:appimage
|
||||||
|
- task: create:deb
|
||||||
|
- task: create:rpm
|
||||||
|
- task: create:aur
|
||||||
|
|
||||||
|
create:appimage:
|
||||||
|
summary: Creates an AppImage
|
||||||
|
dir: build/linux/appimage
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
cmds:
|
||||||
|
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
|
||||||
|
- cp ../../appicon.png "{{.APP_NAME}}.png"
|
||||||
|
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
|
||||||
|
vars:
|
||||||
|
APP_NAME: '{{.APP_NAME}}'
|
||||||
|
APP_BINARY: '../../../bin/{{.APP_NAME}}'
|
||||||
|
ICON: '{{.APP_NAME}}.png'
|
||||||
|
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
|
||||||
|
OUTPUT_DIR: '../../../bin'
|
||||||
|
|
||||||
|
create:deb:
|
||||||
|
summary: Creates a deb package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
- task: generate:deb
|
||||||
|
|
||||||
|
create:rpm:
|
||||||
|
summary: Creates a rpm package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
- task: generate:rpm
|
||||||
|
|
||||||
|
create:aur:
|
||||||
|
summary: Creates a arch linux packager package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
- task: generate:aur
|
||||||
|
|
||||||
|
generate:deb:
|
||||||
|
summary: Creates a deb package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||||
|
|
||||||
|
generate:rpm:
|
||||||
|
summary: Creates a rpm package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||||
|
|
||||||
|
generate:aur:
|
||||||
|
summary: Creates a arch linux packager package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||||
|
|
||||||
|
generate:dotdesktop:
|
||||||
|
summary: Generates a `.desktop` file
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
|
||||||
|
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
|
||||||
|
vars:
|
||||||
|
APP_NAME: '{{.APP_NAME}}'
|
||||||
|
EXEC: '{{.APP_NAME}}'
|
||||||
|
ICON: '{{.APP_NAME}}'
|
||||||
|
CATEGORIES: 'Development;'
|
||||||
|
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
|
||||||
|
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
|
||||||
|
sign:deb:
|
||||||
|
summary: Signs the DEB package
|
||||||
|
desc: |
|
||||||
|
Signs the .deb package with a PGP key.
|
||||||
|
Configure PGP_KEY in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: create:deb
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||||
|
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:rpm:
|
||||||
|
summary: Signs the RPM package
|
||||||
|
desc: |
|
||||||
|
Signs the .rpm package with a PGP key.
|
||||||
|
Configure PGP_KEY in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: create:rpm
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||||
|
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:packages:
|
||||||
|
summary: Signs all Linux packages (DEB and RPM)
|
||||||
|
desc: |
|
||||||
|
Signs both .deb and .rpm packages with a PGP key.
|
||||||
|
Configure PGP_KEY in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
cmds:
|
||||||
|
- task: sign:deb
|
||||||
|
- task: sign:rpm
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||||
|
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||||
35
build/linux/appimage/build.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copyright (c) 2018-Present Lea Anthony
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
# Fail script on any error
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# Define variables
|
||||||
|
APP_DIR="${APP_NAME}.AppDir"
|
||||||
|
|
||||||
|
# Create AppDir structure
|
||||||
|
mkdir -p "${APP_DIR}/usr/bin"
|
||||||
|
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
|
||||||
|
cp "${ICON_PATH}" "${APP_DIR}/"
|
||||||
|
cp "${DESKTOP_FILE}" "${APP_DIR}/"
|
||||||
|
|
||||||
|
if [[ $(uname -m) == *x86_64* ]]; then
|
||||||
|
# Download linuxdeploy and make it executable
|
||||||
|
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
|
||||||
|
chmod +x linuxdeploy-x86_64.AppImage
|
||||||
|
|
||||||
|
# Run linuxdeploy to bundle the application
|
||||||
|
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
|
||||||
|
else
|
||||||
|
# Download linuxdeploy and make it executable (arm64)
|
||||||
|
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
|
||||||
|
chmod +x linuxdeploy-aarch64.AppImage
|
||||||
|
|
||||||
|
# Run linuxdeploy to bundle the application (arm64)
|
||||||
|
./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename the generated AppImage
|
||||||
|
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"
|
||||||
|
|
||||||
13
build/linux/desktop
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Name=U-Desk
|
||||||
|
Comment=A u-desk application
|
||||||
|
# The Exec line includes %u to pass the URL to the application
|
||||||
|
Exec=/usr/local/bin/u-desk.exe %u
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=u-desk.exe
|
||||||
|
Categories=Utility;
|
||||||
|
StartupWMClass=u-desk.exe
|
||||||
|
|
||||||
|
|
||||||
67
build/linux/nfpm/nfpm.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Feel free to remove those if you don't want/need to use them.
|
||||||
|
# Make sure to check the documentation at https://nfpm.goreleaser.com
|
||||||
|
#
|
||||||
|
# The lines below are called `modelines`. See `:help modeline`
|
||||||
|
|
||||||
|
name: "u-desk.exe"
|
||||||
|
arch: ${GOARCH}
|
||||||
|
platform: "linux"
|
||||||
|
version: "0.1.0"
|
||||||
|
section: "default"
|
||||||
|
priority: "extra"
|
||||||
|
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||||
|
description: "A u-desk application"
|
||||||
|
vendor: "My Company"
|
||||||
|
homepage: "https://wails.io"
|
||||||
|
license: "MIT"
|
||||||
|
release: "1"
|
||||||
|
|
||||||
|
contents:
|
||||||
|
- src: "./bin/u-desk.exe"
|
||||||
|
dst: "/usr/local/bin/u-desk.exe"
|
||||||
|
- src: "./build/appicon.png"
|
||||||
|
dst: "/usr/share/icons/hicolor/128x128/apps/u-desk.exe.png"
|
||||||
|
- src: "./build/linux/u-desk.exe.desktop"
|
||||||
|
dst: "/usr/share/applications/u-desk.exe.desktop"
|
||||||
|
|
||||||
|
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
|
||||||
|
depends:
|
||||||
|
- libgtk-3-0
|
||||||
|
- libwebkit2gtk-4.1-0
|
||||||
|
|
||||||
|
# Distribution-specific overrides for different package formats and WebKit versions
|
||||||
|
overrides:
|
||||||
|
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
|
||||||
|
rpm:
|
||||||
|
depends:
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk4.1
|
||||||
|
|
||||||
|
# Arch Linux packages (WebKit 4.1)
|
||||||
|
archlinux:
|
||||||
|
depends:
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk-4.1
|
||||||
|
|
||||||
|
# scripts section to ensure desktop database is updated after install
|
||||||
|
scripts:
|
||||||
|
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
|
||||||
|
# You can also add preremove, postremove if needed
|
||||||
|
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
|
||||||
|
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
|
||||||
|
|
||||||
|
# replaces:
|
||||||
|
# - foobar
|
||||||
|
# provides:
|
||||||
|
# - bar
|
||||||
|
# depends:
|
||||||
|
# - gtk3
|
||||||
|
# - libwebkit2gtk
|
||||||
|
# recommends:
|
||||||
|
# - whatever
|
||||||
|
# suggests:
|
||||||
|
# - something-else
|
||||||
|
# conflicts:
|
||||||
|
# - not-foo
|
||||||
|
# - not-bar
|
||||||
|
# changelog: "changelog.yaml"
|
||||||
21
build/linux/nfpm/scripts/postinstall.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Update desktop database for .desktop file changes
|
||||||
|
# This makes the application appear in application menus and registers its capabilities.
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
echo "Updating desktop database..."
|
||||||
|
update-desktop-database -q /usr/share/applications
|
||||||
|
else
|
||||||
|
echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update MIME database for custom URL schemes (x-scheme-handler)
|
||||||
|
# This ensures the system knows how to handle your custom protocols.
|
||||||
|
if command -v update-mime-database >/dev/null 2>&1; then
|
||||||
|
echo "Updating MIME database..."
|
||||||
|
update-mime-database -n /usr/share/mime
|
||||||
|
else
|
||||||
|
echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
1
build/linux/nfpm/scripts/postremove.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#!/bin/bash
|
||||||
1
build/linux/nfpm/scripts/preinstall.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#!/bin/bash
|
||||||
1
build/linux/nfpm/scripts/preremove.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#!/bin/bash
|
||||||
184
build/windows/Taskfile.yml
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Signing configuration - edit these values for your project
|
||||||
|
# SIGN_CERTIFICATE: "path/to/certificate.pfx"
|
||||||
|
# SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
|
||||||
|
# TIMESTAMP_SERVER: "http://timestamp.digicert.com"
|
||||||
|
#
|
||||||
|
# Password is stored securely in system keychain. Run: wails3 setup signing
|
||||||
|
|
||||||
|
# Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
|
||||||
|
CROSS_IMAGE: wails-cross
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application for Windows
|
||||||
|
cmds:
|
||||||
|
# Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
|
||||||
|
- task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH}}'
|
||||||
|
DEV: '{{.DEV}}'
|
||||||
|
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||||
|
vars:
|
||||||
|
# Default to CGO_ENABLED=0 if not explicitly set
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||||
|
|
||||||
|
build:native:
|
||||||
|
summary: Builds the application using native Go cross-compilation
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
cmds:
|
||||||
|
- task: generate:syso
|
||||||
|
- go build {{.BUILD_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 .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
|
||||||
|
env:
|
||||||
|
GOOS: windows
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:build:frontend
|
||||||
|
- task: common:generate:icons
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required for CGO cross-compilation. Please install Docker."
|
||||||
|
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||||
|
msg: |
|
||||||
|
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||||
|
Build it first: wails3 task setup:docker
|
||||||
|
cmds:
|
||||||
|
- task: generate:syso
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||||
|
- rm -f *.syso
|
||||||
|
vars:
|
||||||
|
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||||
|
# Mount Go module cache for faster builds
|
||||||
|
GO_CACHE_MOUNT:
|
||||||
|
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||||
|
# Extract replace directives from go.mod and create -v mounts for each
|
||||||
|
REPLACE_MOUNTS:
|
||||||
|
sh: |
|
||||||
|
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||||
|
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||||
|
# Convert relative paths to absolute
|
||||||
|
if [ "${path#/}" = "$path" ]; then
|
||||||
|
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||||
|
fi
|
||||||
|
# Only mount if directory exists
|
||||||
|
if [ -d "$path" ]; then
|
||||||
|
echo "-v $path:$path:ro"
|
||||||
|
fi
|
||||||
|
done | tr '\n' ' '
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages the application
|
||||||
|
cmds:
|
||||||
|
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
|
||||||
|
vars:
|
||||||
|
FORMAT: '{{.FORMAT | default "nsis"}}'
|
||||||
|
|
||||||
|
generate:syso:
|
||||||
|
summary: Generates Windows `.syso` file
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
|
||||||
|
create:nsis:installer:
|
||||||
|
summary: Creates an NSIS installer
|
||||||
|
dir: build/windows/nsis
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
# Create the Microsoft WebView2 bootstrapper if it doesn't exist
|
||||||
|
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
|
||||||
|
- |
|
||||||
|
{{if eq OS "windows"}}
|
||||||
|
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
|
||||||
|
{{else}}
|
||||||
|
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
|
||||||
|
{{end}}
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
|
||||||
|
|
||||||
|
create:msix:package:
|
||||||
|
summary: Creates an MSIX package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- |-
|
||||||
|
wails3 tool msix \
|
||||||
|
--config "{{.ROOT_DIR}}/wails.json" \
|
||||||
|
--name "{{.APP_NAME}}" \
|
||||||
|
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
|
||||||
|
--arch "{{.ARCH}}" \
|
||||||
|
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
|
||||||
|
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
|
||||||
|
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
|
||||||
|
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
CERT_PATH: '{{.CERT_PATH | default ""}}'
|
||||||
|
PUBLISHER: '{{.PUBLISHER | default ""}}'
|
||||||
|
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
|
||||||
|
|
||||||
|
install:msix:tools:
|
||||||
|
summary: Installs tools required for MSIX packaging
|
||||||
|
cmds:
|
||||||
|
- wails3 tool msix-install-tools
|
||||||
|
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
|
||||||
|
|
||||||
|
sign:
|
||||||
|
summary: Signs the Windows executable
|
||||||
|
desc: |
|
||||||
|
Signs the .exe with an Authenticode certificate.
|
||||||
|
Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
|
||||||
|
msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:installer:
|
||||||
|
summary: Signs the NSIS installer
|
||||||
|
desc: |
|
||||||
|
Creates and signs the NSIS installer.
|
||||||
|
Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: create:nsis:installer
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
|
||||||
|
msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
|
||||||
BIN
build/windows/app-icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
44
build/windows/convert-ico.ps1
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$srcPath = "E:\wk-lab\u-desk\build\windows\app-icon.png"
|
||||||
|
$icoPath = "E:\wk-lab\u-desk\build\windows\icon.ico"
|
||||||
|
$sizes = @(256, 128, 64, 48, 32, 16)
|
||||||
|
|
||||||
|
$src = [System.Drawing.Image]::FromFile($srcPath)
|
||||||
|
$fs = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create)
|
||||||
|
$w = New-Object System.IO.BinaryWriter($fs)
|
||||||
|
|
||||||
|
$w.Write([uint16]0)
|
||||||
|
$w.Write([uint16]1)
|
||||||
|
$w.Write([uint16]$sizes.Count)
|
||||||
|
|
||||||
|
foreach ($sz in $sizes) {
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap($sz, $sz, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.DrawImage($src, 0, 0, $sz, $sz)
|
||||||
|
$g.Dispose()
|
||||||
|
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bytes = $ms.ToArray()
|
||||||
|
$ms.Dispose()
|
||||||
|
$bmp.Dispose()
|
||||||
|
|
||||||
|
$w.Write([uint32]40)
|
||||||
|
$w.Write([int32]$sz)
|
||||||
|
$w.Write([int32]$sz)
|
||||||
|
$w.Write([uint16]1)
|
||||||
|
$w.Write([uint32]32)
|
||||||
|
$w.Write([uint32]$bytes.Length)
|
||||||
|
$w.Write([uint32]22)
|
||||||
|
$w.Write($bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
$w.Close()
|
||||||
|
$fs.Close()
|
||||||
|
$src.Dispose()
|
||||||
|
|
||||||
|
$item = Get-Item $icoPath
|
||||||
|
Write-Output "ICO: $($item.Name) ($([math]::Round($item.Length / 1KB)) KB)"
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 55 KiB |
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"fixed": {
|
"fixed": {
|
||||||
"file_version": "{{.Info.ProductVersion}}"
|
"file_version": "0.4.0"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"0000": {
|
"0000": {
|
||||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
"ProductVersion": "0.4.0",
|
||||||
"CompanyName": "{{.Info.CompanyName}}",
|
"CompanyName": "1216.top",
|
||||||
"FileDescription": "{{.Info.ProductName}}",
|
"FileDescription": "U-Desk 桌面文件管理器",
|
||||||
"LegalCopyright": "{{.Info.Copyright}}",
|
"LegalCopyright": "© 2026, 1216.top",
|
||||||
"ProductName": "{{.Info.ProductName}}",
|
"ProductName": "U-Desk",
|
||||||
"Comments": "{{.Info.Comments}}"
|
"Comments": "桌面文件管理器"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
55
build/windows/msix/app_manifest.xml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Package
|
||||||
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||||
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
|
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||||
|
IgnorableNamespaces="uap3">
|
||||||
|
|
||||||
|
<Identity
|
||||||
|
Name="com.example.udesk"
|
||||||
|
Publisher="CN=My Company"
|
||||||
|
Version="0.1.0.0"
|
||||||
|
ProcessorArchitecture="x64" />
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>U-Desk</DisplayName>
|
||||||
|
<PublisherDisplayName>My Company</PublisherDisplayName>
|
||||||
|
<Description>A u-desk application</Description>
|
||||||
|
<Logo>Assets\StoreLogo.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="en-us" />
|
||||||
|
</Resources>
|
||||||
|
|
||||||
|
<Applications>
|
||||||
|
<Application Id="com.example.udesk" Executable="u-desk.exe" EntryPoint="Windows.FullTrustApplication">
|
||||||
|
<uap:VisualElements
|
||||||
|
DisplayName="U-Desk"
|
||||||
|
Description="A u-desk application"
|
||||||
|
BackgroundColor="transparent"
|
||||||
|
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||||
|
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||||
|
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||||
|
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||||
|
</uap:VisualElements>
|
||||||
|
|
||||||
|
<Extensions>
|
||||||
|
<desktop:Extension Category="windows.fullTrustProcess" Executable="u-desk.exe" />
|
||||||
|
|
||||||
|
|
||||||
|
</Extensions>
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
|
||||||
|
<Capabilities>
|
||||||
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
|
||||||
|
</Capabilities>
|
||||||
|
</Package>
|
||||||
54
build/windows/msix/template.xml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<MsixPackagingToolTemplate
|
||||||
|
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
|
||||||
|
<Settings
|
||||||
|
AllowTelemetry="false"
|
||||||
|
ApplyACLsToPackageFiles="true"
|
||||||
|
GenerateCommandLineFile="true"
|
||||||
|
AllowPromptForPassword="false">
|
||||||
|
</Settings>
|
||||||
|
<Installer
|
||||||
|
Path="u-desk.exe"
|
||||||
|
Arguments=""
|
||||||
|
InstallLocation="C:\Program Files\My Company\U-Desk">
|
||||||
|
</Installer>
|
||||||
|
<PackageInformation
|
||||||
|
PackageName="U-Desk"
|
||||||
|
PackageDisplayName="U-Desk"
|
||||||
|
PublisherName="CN=My Company"
|
||||||
|
PublisherDisplayName="My Company"
|
||||||
|
Version="0.1.0.0"
|
||||||
|
PackageDescription="A u-desk application">
|
||||||
|
<Capabilities>
|
||||||
|
<Capability Name="runFullTrust" />
|
||||||
|
|
||||||
|
</Capabilities>
|
||||||
|
<Applications>
|
||||||
|
<Application
|
||||||
|
Id="com.example.udesk"
|
||||||
|
Description="A u-desk application"
|
||||||
|
DisplayName="U-Desk"
|
||||||
|
ExecutableName="u-desk.exe"
|
||||||
|
EntryPoint="Windows.FullTrustApplication">
|
||||||
|
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="en-us" />
|
||||||
|
</Resources>
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
<Properties>
|
||||||
|
<Framework>false</Framework>
|
||||||
|
<DisplayName>U-Desk</DisplayName>
|
||||||
|
<PublisherDisplayName>My Company</PublisherDisplayName>
|
||||||
|
<Description>A u-desk application</Description>
|
||||||
|
<Logo>Assets\AppIcon.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
</PackageInformation>
|
||||||
|
<SaveLocation PackagePath="u-desk.msix" />
|
||||||
|
<PackageIntegrity>
|
||||||
|
<CertificatePath></CertificatePath>
|
||||||
|
</PackageIntegrity>
|
||||||
|
</MsixPackagingToolTemplate>
|
||||||
114
build/windows/nsis/project.nsi
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
Unicode true
|
||||||
|
|
||||||
|
####
|
||||||
|
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||||
|
## mentioned underneath.
|
||||||
|
## If the keyword is not defined, "wails_tools.nsh" will populate them.
|
||||||
|
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
|
||||||
|
## from outside of Wails for debugging and development of the installer.
|
||||||
|
##
|
||||||
|
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||||
|
## > wails build --target windows/amd64 --nsis
|
||||||
|
## Then you can call makensis on this file with specifying the path to your binary:
|
||||||
|
## For a AMD64 only installer:
|
||||||
|
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||||
|
## For a ARM64 only installer:
|
||||||
|
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||||
|
## For a installer with both architectures:
|
||||||
|
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||||
|
####
|
||||||
|
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
|
||||||
|
####
|
||||||
|
## !define INFO_PROJECTNAME "my-project" # Default "u-desk"
|
||||||
|
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
|
||||||
|
## !define INFO_PRODUCTNAME "My Product Name" # Default "U-Desk"
|
||||||
|
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
|
||||||
|
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
|
||||||
|
###
|
||||||
|
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||||
|
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||||
|
####
|
||||||
|
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||||
|
####
|
||||||
|
## Include the wails tools
|
||||||
|
####
|
||||||
|
!include "wails_tools.nsh"
|
||||||
|
|
||||||
|
# The version information for this two must consist of 4 parts
|
||||||
|
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||||
|
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||||
|
|
||||||
|
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||||
|
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||||
|
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||||
|
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||||
|
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||||
|
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||||
|
|
||||||
|
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||||
|
ManifestDPIAware true
|
||||||
|
|
||||||
|
!include "MUI.nsh"
|
||||||
|
|
||||||
|
!define MUI_ICON "..\icon.ico"
|
||||||
|
!define MUI_UNICON "..\icon.ico"
|
||||||
|
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||||
|
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||||
|
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||||
|
|
||||||
|
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||||
|
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||||
|
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||||
|
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||||
|
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||||
|
|
||||||
|
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
|
||||||
|
|
||||||
|
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||||
|
|
||||||
|
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||||
|
#!uninstfinalize 'signtool --file "%1"'
|
||||||
|
#!finalize 'signtool --file "%1"'
|
||||||
|
|
||||||
|
Name "${INFO_PRODUCTNAME}"
|
||||||
|
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||||
|
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||||
|
ShowInstDetails show # This will always show the installation details.
|
||||||
|
|
||||||
|
Function .onInit
|
||||||
|
!insertmacro wails.checkArchitecture
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Section
|
||||||
|
!insertmacro wails.setShellContext
|
||||||
|
|
||||||
|
!insertmacro wails.webview2runtime
|
||||||
|
|
||||||
|
SetOutPath $INSTDIR
|
||||||
|
|
||||||
|
!insertmacro wails.files
|
||||||
|
|
||||||
|
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||||
|
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||||
|
|
||||||
|
!insertmacro wails.associateFiles
|
||||||
|
!insertmacro wails.associateCustomProtocols
|
||||||
|
|
||||||
|
!insertmacro wails.writeUninstaller
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "uninstall"
|
||||||
|
!insertmacro wails.setShellContext
|
||||||
|
|
||||||
|
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||||
|
|
||||||
|
RMDir /r $INSTDIR
|
||||||
|
|
||||||
|
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||||
|
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||||
|
|
||||||
|
!insertmacro wails.unassociateFiles
|
||||||
|
!insertmacro wails.unassociateCustomProtocols
|
||||||
|
|
||||||
|
!insertmacro wails.deleteUninstaller
|
||||||
|
SectionEnd
|
||||||
236
build/windows/nsis/wails_tools.nsh
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# DO NOT EDIT - Generated automatically by `wails build`
|
||||||
|
|
||||||
|
!include "x64.nsh"
|
||||||
|
!include "WinVer.nsh"
|
||||||
|
!include "FileFunc.nsh"
|
||||||
|
|
||||||
|
!ifndef INFO_PROJECTNAME
|
||||||
|
!define INFO_PROJECTNAME "u-desk"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_COMPANYNAME
|
||||||
|
!define INFO_COMPANYNAME "My Company"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_PRODUCTNAME
|
||||||
|
!define INFO_PRODUCTNAME "U-Desk"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_PRODUCTVERSION
|
||||||
|
!define INFO_PRODUCTVERSION "0.1.0"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_COPYRIGHT
|
||||||
|
!define INFO_COPYRIGHT "© 2026, My Company"
|
||||||
|
!endif
|
||||||
|
!ifndef PRODUCT_EXECUTABLE
|
||||||
|
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||||
|
!endif
|
||||||
|
!ifndef UNINST_KEY_NAME
|
||||||
|
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||||
|
!endif
|
||||||
|
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||||
|
|
||||||
|
!ifndef REQUEST_EXECUTION_LEVEL
|
||||||
|
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||||
|
|
||||||
|
!ifdef ARG_WAILS_AMD64_BINARY
|
||||||
|
!define SUPPORTS_AMD64
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef ARG_WAILS_ARM64_BINARY
|
||||||
|
!define SUPPORTS_ARM64
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef SUPPORTS_AMD64
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
!define ARCH "amd64_arm64"
|
||||||
|
!else
|
||||||
|
!define ARCH "amd64"
|
||||||
|
!endif
|
||||||
|
!else
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
!define ARCH "arm64"
|
||||||
|
!else
|
||||||
|
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||||
|
!endif
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!macro wails.checkArchitecture
|
||||||
|
!ifndef WAILS_WIN10_REQUIRED
|
||||||
|
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||||
|
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
${If} ${AtLeastWin10}
|
||||||
|
!ifdef SUPPORTS_AMD64
|
||||||
|
${if} ${IsNativeAMD64}
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
${if} ${IsNativeARM64}
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
|
||||||
|
IfSilent silentArch notSilentArch
|
||||||
|
silentArch:
|
||||||
|
SetErrorLevel 65
|
||||||
|
Abort
|
||||||
|
notSilentArch:
|
||||||
|
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||||
|
Quit
|
||||||
|
${else}
|
||||||
|
IfSilent silentWin notSilentWin
|
||||||
|
silentWin:
|
||||||
|
SetErrorLevel 64
|
||||||
|
Abort
|
||||||
|
notSilentWin:
|
||||||
|
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||||
|
Quit
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
ok:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.files
|
||||||
|
!ifdef SUPPORTS_AMD64
|
||||||
|
${if} ${IsNativeAMD64}
|
||||||
|
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
${if} ${IsNativeARM64}
|
||||||
|
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.writeUninstaller
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
SetRegView 64
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||||
|
|
||||||
|
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||||
|
IntFmt $0 "0x%08X" $0
|
||||||
|
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.deleteUninstaller
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
SetRegView 64
|
||||||
|
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.setShellContext
|
||||||
|
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||||
|
SetShellVarContext all
|
||||||
|
${else}
|
||||||
|
SetShellVarContext current
|
||||||
|
${EndIf}
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
# Install webview2 by launching the bootstrapper
|
||||||
|
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||||
|
!macro wails.webview2runtime
|
||||||
|
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||||
|
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
SetRegView 64
|
||||||
|
# If the admin key exists and is not empty then webview2 is already installed
|
||||||
|
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||||
|
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||||
|
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||||
|
SetDetailsPrint listonly
|
||||||
|
|
||||||
|
InitPluginsDir
|
||||||
|
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||||
|
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||||
|
File "MicrosoftEdgeWebview2Setup.exe"
|
||||||
|
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
ok:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||||
|
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||||
|
; Backup the previously associated file class
|
||||||
|
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||||
|
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||||
|
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||||
|
; Backup the previously associated file class
|
||||||
|
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||||
|
|
||||||
|
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.associateFiles
|
||||||
|
; Create file associations
|
||||||
|
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.unassociateFiles
|
||||||
|
; Delete app associations
|
||||||
|
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||||
|
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||||
|
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.associateCustomProtocols
|
||||||
|
; Create custom protocols associations
|
||||||
|
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.unassociateCustomProtocols
|
||||||
|
; Delete app custom protocol associations
|
||||||
|
|
||||||
|
!macroend
|
||||||
BIN
build/windows/u-desk-icon.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
<assemblyIdentity type="win32" name="com.example.udesk" version="0.1.0" processorArchitecture="*"/>
|
||||||
<dependency>
|
<dependency>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
@@ -12,4 +12,11 @@
|
|||||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||||
</asmv3:windowsSettings>
|
</asmv3:windowsSettings>
|
||||||
</asmv3:application>
|
</asmv3:application>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
</assembly>
|
</assembly>
|
||||||
106
cmd/agent/main.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/agent/config"
|
||||||
|
agentmw "u-desk/internal/agent/middleware"
|
||||||
|
"u-desk/internal/agent/handler"
|
||||||
|
"u-desk/internal/filesystem"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load("configs/agent.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[FATAL] 加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fsConfig := filesystem.DefaultConfig()
|
||||||
|
fsSvc, err := filesystem.NewFileSystemService(fsConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[FATAL] 初始化文件服务失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: cfg.CORS.AllowedOrigins,
|
||||||
|
AllowMethods: []string{echo.GET, echo.PUT, echo.POST, echo.DELETE, echo.PATCH, echo.OPTIONS},
|
||||||
|
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization, echo.HeaderAccept},
|
||||||
|
}))
|
||||||
|
if cfg.Auth.Token != "" {
|
||||||
|
e.Use(agentmw.Auth(cfg.Auth.Token))
|
||||||
|
}
|
||||||
|
|
||||||
|
h := handler.New(fsSvc, cfg)
|
||||||
|
|
||||||
|
api := e.Group("/api/v1")
|
||||||
|
{
|
||||||
|
api.GET("/ping", h.Ping)
|
||||||
|
api.GET("/info", h.Info)
|
||||||
|
|
||||||
|
// 文件操作 — 所有通过 ?path= 参数传递路径
|
||||||
|
api.GET("/fs", h.ListOrStat) // ?path=xxx [&action=stat]
|
||||||
|
api.GET("/fs/read", h.ReadFile) // ?path=xxx
|
||||||
|
api.PUT("/fs/write", h.WriteFile) // ?path=xxx & body={content}
|
||||||
|
api.POST("/fs/create", h.Create) // ?path=xxx & body={type,name}
|
||||||
|
api.DELETE("/fs/delete", h.Delete) // ?path=xxx
|
||||||
|
api.PATCH("/fs/rename", h.Rename) // ?path=xxx & body={new_path}
|
||||||
|
api.POST("/fs/upload", h.Upload) // ?path=xxx & body={content}
|
||||||
|
api.GET("/fs/detect", h.DetectType) // ?path=xxx
|
||||||
|
|
||||||
|
sys := api.Group("/system")
|
||||||
|
{
|
||||||
|
sys.GET("/common-paths", h.CommonPaths)
|
||||||
|
sys.GET("/drives", h.Drives)
|
||||||
|
sys.GET("/stats", h.Stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := api.Group("/proxy")
|
||||||
|
{
|
||||||
|
proxy.GET("/localfs/*", h.FileServerProxy)
|
||||||
|
proxy.GET("/html-preview", h.HTMLPreviewProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
go func() {
|
||||||
|
log.Printf("[INFO] u-fs-agent 启动于 %s", addr)
|
||||||
|
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("[FATAL] HTTP 服务器错误: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := filesystem.StartLocalFileServer(); err != nil {
|
||||||
|
log.Printf("[WARN] 文件服务器启动失败(媒体预览不可用): %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
log.Println("[INFO] 正在关闭...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
filesystem.ShutdownLocalFileServer()
|
||||||
|
e.Shutdown(ctx)
|
||||||
|
fsSvc.Close(ctx)
|
||||||
|
log.Println("[INFO] 已关闭")
|
||||||
|
}
|
||||||
53
cmd/dbread/main.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
dbPath := filepath.Join(home, ".u-desk", "app.db")
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "open db:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有列
|
||||||
|
type Profile struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Type string `gorm:"column:type"`
|
||||||
|
Host string `gorm:"column:host"`
|
||||||
|
Port int `gorm:"column:port"`
|
||||||
|
Username string `gorm:"column:username"`
|
||||||
|
Provider string `gorm:"column:provider"`
|
||||||
|
Token string `gorm:"column:token"`
|
||||||
|
AccessKey string `gorm:"column:access_key"`
|
||||||
|
SecretKey string `gorm:"column:secret_key"`
|
||||||
|
Bucket string `gorm:"column:bucket"`
|
||||||
|
Region string `gorm:"column:region"`
|
||||||
|
Endpoint string `gorm:"column:endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []Profile
|
||||||
|
db.Table("connection_profiles").Find(&profiles)
|
||||||
|
|
||||||
|
// 脱敏 secret_key
|
||||||
|
for i := range profiles {
|
||||||
|
if len(profiles[i].SecretKey) > 8 {
|
||||||
|
profiles[i].SecretKey = profiles[i].SecretKey[:4] + "****"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b, _ := json.MarshalIndent(profiles, "", " ")
|
||||||
|
fmt.Println("=== Connection Profiles ===")
|
||||||
|
fmt.Println(string(b))
|
||||||
|
}
|
||||||
29
configs/agent.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# u-fs-agent 配置文件
|
||||||
|
# 部署到远端服务器后修改此文件
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 9876 # 监听端口
|
||||||
|
host: "0.0.0.0" # 监听地址
|
||||||
|
|
||||||
|
auth:
|
||||||
|
token: "" # API Token(留空则不验证,生产环境必须设置)
|
||||||
|
# 生成随机 token: openssl rand -hex 32
|
||||||
|
|
||||||
|
cors:
|
||||||
|
allowed_origins:
|
||||||
|
- "*" # 开发模式允许所有来源
|
||||||
|
# 生产环境建议限定:
|
||||||
|
# - "http://localhost:5173"
|
||||||
|
# - "http://localhost:5174"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "info" # debug / info / warn / error
|
||||||
|
format: "json" # json / text
|
||||||
|
|
||||||
|
file_server:
|
||||||
|
port: 2652 # 内置文件服务器端口(用于媒体预览代理)
|
||||||
|
max_file_size: 524288000 # 最大文件大小 500MB
|
||||||
|
|
||||||
|
security:
|
||||||
|
allow_symlinks: false # 是否允许符号链接
|
||||||
|
check_system_paths: true # 检查系统关键目录
|
||||||
16
devtools.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !production
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openDevTools(window *application.WebviewWindow) {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
window.OpenDevTools()
|
||||||
|
}()
|
||||||
|
}
|
||||||
7
devtools_prod.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build production
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
|
func openDevTools(window *application.WebviewWindow) {}
|
||||||
234
docs/01-技术文档/CodeMirror/CodeEditor-优化报告.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# CodeEditor 优化完成报告
|
||||||
|
|
||||||
|
> **日期**: 2026-02-05
|
||||||
|
> **组件**: CodeEditor.vue
|
||||||
|
> **优化内容**: 性能、用户体验、代码质量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 完成的优化
|
||||||
|
|
||||||
|
### 1. 🔴 使用 Compartment 重构主题切换(P0)
|
||||||
|
|
||||||
|
**问题**:之前每次切换主题都重建整个编辑器,导致闪烁和状态丢失
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```javascript
|
||||||
|
import { Compartment } from '@codemirror/state'
|
||||||
|
|
||||||
|
const themeCompartment = new Compartment()
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
// 动态切换主题(不丢失状态)
|
||||||
|
watch(isDark, () => {
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ 主题切换流畅,无闪烁
|
||||||
|
- ✅ 保留滚动位置和光标位置
|
||||||
|
- ✅ CodeEditor.js 从 6.24 kB 减小到 2.94 kB(减小 53%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 🟡 修复 TypeScript 语言配置(P1)
|
||||||
|
|
||||||
|
**问题**:TypeScript 文件使用 `{ jsx: true }` 而非 `{ typescript: true }`
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
```javascript
|
||||||
|
// 修复前
|
||||||
|
typescript: ['@codemirror/lang-javascript', 'javascript', { jsx: true }]
|
||||||
|
|
||||||
|
// 修复后
|
||||||
|
typescript: ['@codemirror/lang-javascript', 'javascript', {
|
||||||
|
typescript: true,
|
||||||
|
jsx: true
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ TypeScript 语法高亮正确
|
||||||
|
- ✅ TSX 文件也支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 🟡 添加常用语言预加载(P1)
|
||||||
|
|
||||||
|
**实现**:在 App.vue 的 onMounted 中调用
|
||||||
|
```javascript
|
||||||
|
import { preloadCommonLanguages } from './utils/codeMirrorLoader'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
preloadCommonLanguages() // 预加载 js, json, md, python, sql
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ 打开常用文件(js、json、md)时瞬间加载
|
||||||
|
- ✅ 提升用户体验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 🟡 添加内容更新防抖(P2)
|
||||||
|
|
||||||
|
**问题**:每次输入都触发 emit,可能影响性能
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```javascript
|
||||||
|
let emitTimeout = null
|
||||||
|
const debouncedEmit = (value) => {
|
||||||
|
if (emitTimeout) clearTimeout(emitTimeout)
|
||||||
|
emitTimeout = setTimeout(() => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}, 150) // 150ms 防抖
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ 减少不必要的更新
|
||||||
|
- ✅ 提升打字性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 🟢 补充语法标签(P3)
|
||||||
|
|
||||||
|
**新增标签**:
|
||||||
|
```javascript
|
||||||
|
{ tag: tags.definition(tags.name), color: '#22863a' },
|
||||||
|
{ tag: tags.typeName, color: '#22863a' },
|
||||||
|
{ tag: tags.self, color: '#005cc5' },
|
||||||
|
{ tag: tags.special(tags.variableName), color: '#005cc5' },
|
||||||
|
{ tag: tags.modifier, color: '#d73a49' },
|
||||||
|
{ tag: tags.regexp, color: '#032f62' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ 高亮更完整
|
||||||
|
- ✅ 支持更多语法结构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 🟢 改进错误处理(P3)
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
const langExtension = await loadLanguageExtension(language)
|
||||||
|
if (langExtension) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(langExtension)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[CodeEditor] 加载语言包失败: ${language}`, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- ✅ 语言加载失败时不影响编辑器使用
|
||||||
|
- ✅ 降级到纯文本模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 性能对比
|
||||||
|
|
||||||
|
| 指标 | 优化前 | 优化后 | 改进 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| CodeEditor.js 大小 | 6.24 kB | 2.94 kB | **↓ 53%** |
|
||||||
|
| 主题切换时间 | 100ms+ (重建) | ~10ms (reconfigure) | **↑ 10倍** |
|
||||||
|
| 首次语言加载 | 同步加载 | 异步预加载 | **瞬间** |
|
||||||
|
| 输入防抖 | 无 | 150ms | **性能提升** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 架构改进
|
||||||
|
|
||||||
|
### 代码组织
|
||||||
|
|
||||||
|
**优化前**:
|
||||||
|
```javascript
|
||||||
|
// 混乱的监听器
|
||||||
|
watch([isDark, () => props.fileExtension], async () => {
|
||||||
|
await nextTick()
|
||||||
|
await recreateEditor() // 重建整个编辑器
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后**:
|
||||||
|
```javascript
|
||||||
|
// 清晰的职责分离
|
||||||
|
const themeCompartment = new Compartment() // 主题隔离
|
||||||
|
const languageCompartment = new Compartment() // 语言隔离
|
||||||
|
|
||||||
|
// 独立的监听器
|
||||||
|
watch(isDark, () => { /* 只切换主题 */ })
|
||||||
|
watch(() => props.fileExtension, () => { /* 只加载语言 */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 代码注释
|
||||||
|
|
||||||
|
添加了清晰的分段注释:
|
||||||
|
```javascript
|
||||||
|
// ==================== 主题定义 ====================
|
||||||
|
// ==================== Props & Emits ====================
|
||||||
|
// ==================== 状态管理 ====================
|
||||||
|
// ==================== 防抖处理 ====================
|
||||||
|
// ==================== 扩展配置 ====================
|
||||||
|
// ==================== 编辑器创建 ====================
|
||||||
|
// ==================== 语言管理 ====================
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
// ==================== 监听器 ====================
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 符合最佳实践
|
||||||
|
|
||||||
|
根据 [CodeMirror 6 文档](./CodeMirror-6-编辑器文档.md):
|
||||||
|
|
||||||
|
✅ **使用 Compartment 动态切换** - 避免重建编辑器
|
||||||
|
✅ **异步加载语言包** - 按需加载,减少初始体积
|
||||||
|
✅ **语言缓存机制** - 避免重复加载
|
||||||
|
✅ **防抖更新** - 提升性能
|
||||||
|
✅ **完整的语法标签** - 更好的高亮效果
|
||||||
|
✅ **错误边界** - 优雅降级
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 后续建议
|
||||||
|
|
||||||
|
### 短期(可选)
|
||||||
|
- [ ] 添加代码折叠功能
|
||||||
|
- [ ] 添加括号匹配高亮
|
||||||
|
- [ ] 支持多光标编辑
|
||||||
|
|
||||||
|
### 中期(可选)
|
||||||
|
- [ ] 集成 LSP(语言服务器协议)
|
||||||
|
- [ ] 添加自动补全
|
||||||
|
- [ ] 添加代码片段支持
|
||||||
|
|
||||||
|
### 长期(可选)
|
||||||
|
- [ ] 支持协同编辑
|
||||||
|
- [ ] 添加 diff 模式
|
||||||
|
- [ ] 支持 Vim 模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- `frontend/src/components/CodeEditor.vue` - 主编辑器组件
|
||||||
|
- `frontend/src/utils/codeMirrorLoader.js` - 语言包加载器
|
||||||
|
- `frontend/src/App.vue` - 添加预加载调用
|
||||||
|
- `docs/CodeMirror-6-编辑器文档.md` - 完整技术文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**优化完成时间**: 2026-02-05
|
||||||
|
**构建状态**: ✅ 成功
|
||||||
|
**测试状态**: 待测试
|
||||||
687
docs/01-技术文档/CodeMirror/CodeMirror-6-编辑器文档.md
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
# CodeMirror 6 编辑器文档
|
||||||
|
|
||||||
|
> **项目**: U-Desk
|
||||||
|
> **组件**: CodeEditor.vue
|
||||||
|
> **更新日期**: 2026-02-05
|
||||||
|
> **维护者**: 开发团队
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 目录
|
||||||
|
|
||||||
|
- [简介](#简介)
|
||||||
|
- [版本信息](#版本信息)
|
||||||
|
- [核心架构](#核心架构)
|
||||||
|
- [主题系统](#主题系统)
|
||||||
|
- [语言支持](#语言支持)
|
||||||
|
- [API 参考](#api-参考)
|
||||||
|
- [最佳实践](#最佳实践)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
- [升级指南](#升级指南)
|
||||||
|
- [参考资料](#参考资料)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
### 什么是 CodeMirror 6?
|
||||||
|
|
||||||
|
CodeMirror 6 是一个**基于 TypeScript 重写的现代代码编辑器**,采用模块化架构,提供:
|
||||||
|
|
||||||
|
- 🚀 **高性能**: 比 v5 快 40%,内存少 35%
|
||||||
|
- 📦 **模块化**: 只加载需要的功能
|
||||||
|
- 🎨 **可定制**: 灵活的主题和扩展系统
|
||||||
|
- 🔍 **准确**: 基于 Lezer 的语法解析
|
||||||
|
- 💪 **类型安全**: 完整的 TypeScript 支持
|
||||||
|
|
||||||
|
### 为什么选择 CodeMirror 6?
|
||||||
|
|
||||||
|
| 特性 | CodeMirror 6 | Monaco (VS Code) | Ace |
|
||||||
|
|------|--------------|------------------|-----|
|
||||||
|
| 包体积 | ~50KB (gzip) | ~2MB | ~300KB |
|
||||||
|
| TypeScript | ✅ 原生支持 | ✅ 支持 | ⚠️ 部分 |
|
||||||
|
| 模块化 | ✅ 高度模块化 | ❌ 单体 | ⚠️ 中等 |
|
||||||
|
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||||
|
| 移动端 | ✅ 良好支持 | ⚠️ 一般 | ⚠️ 一般 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本信息
|
||||||
|
|
||||||
|
### 当前使用的版本
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@codemirror/view": "6.39.8",
|
||||||
|
"@codemirror/state": "6.5.3",
|
||||||
|
"@codemirror/language": "6.12.1",
|
||||||
|
"@codemirror/commands": "6.10.1",
|
||||||
|
"@codemirror/highlight": "0.19.8",
|
||||||
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
|
"@codemirror/lang-javascript": "6.2.4",
|
||||||
|
"@codemirror/lang-python": "6.2.1",
|
||||||
|
"@codemirror/lang-go": "6.0.1",
|
||||||
|
"@codemirror/lang-json": "6.0.2",
|
||||||
|
"@codemirror/legacy-modes": "6.5.2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 最新版本(2026-02)
|
||||||
|
|
||||||
|
- **@codemirror/view**: 6.39.12 (2026-01-30)
|
||||||
|
- **@codemirror/state**: 6.5.3 (当前版本)
|
||||||
|
- **@codemirror/language**: 6.12.1 (当前版本)
|
||||||
|
|
||||||
|
> 注:我们的版本略旧但稳定,建议在下次迭代时更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心架构
|
||||||
|
|
||||||
|
### 包结构
|
||||||
|
|
||||||
|
```
|
||||||
|
@codemirror/
|
||||||
|
├── view/ # 编辑器视图和 DOM 交互
|
||||||
|
├── state/ # 编辑器状态和事务
|
||||||
|
├── language/ # 语言支持和高亮
|
||||||
|
├── commands/ # 内置命令
|
||||||
|
├── search/ # 搜索和替换
|
||||||
|
├── autocomplete/ # 自动补全
|
||||||
|
├── lint/ # 代码检查
|
||||||
|
├── lang-*/ # 语言包
|
||||||
|
└── legacy-modes/ # 旧版语言模式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
#### 1. EditorState(状态)
|
||||||
|
|
||||||
|
编辑器的不可变状态,包含文档内容:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { EditorState } from '@codemirror/state'
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: 'console.log("Hello, World!")',
|
||||||
|
extensions: [/* ... */]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. EditorView(视图)
|
||||||
|
|
||||||
|
编辑器的 UI 表示:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state: state,
|
||||||
|
parent: document.body
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Extensions(扩展)
|
||||||
|
|
||||||
|
配置编辑器功能的核心机制:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const extensions = [
|
||||||
|
lineNumbers(), // 显示行号
|
||||||
|
highlightActiveLine(), // 高亮当前行
|
||||||
|
history(), // 撤销/重做
|
||||||
|
keymap.of(defaultKeymap) // 键盘映射
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主题系统
|
||||||
|
|
||||||
|
### 主题构成
|
||||||
|
|
||||||
|
CodeMirror 6 的主题由两部分组成:
|
||||||
|
|
||||||
|
1. **基础样式** (`EditorView.theme`) - UI 元素样式
|
||||||
|
2. **高亮样式** (`HighlightStyle.define`) - 语法高亮颜色
|
||||||
|
|
||||||
|
### 暗色主题(One Dark)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
|
||||||
|
// 直接使用
|
||||||
|
extensions.push(oneDark)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 亮色主题(自定义)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { HighlightStyle } from '@codemirror/language'
|
||||||
|
import { tags } from '@lezer/highlight'
|
||||||
|
|
||||||
|
// 1. 定义语法高亮样式
|
||||||
|
const lightHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.keyword, color: '#d73a49', fontWeight: 'bold' },
|
||||||
|
{ tag: tags.string, color: '#032f62' },
|
||||||
|
{ tag: tags.number, color: '#005cc5' },
|
||||||
|
{ tag: tags.comment, color: '#6a737d', fontStyle: 'italic' },
|
||||||
|
{ tag: tags.function(tags.variableName), color: '#6f42c1' },
|
||||||
|
{ tag: tags.className, color: '#22863a' },
|
||||||
|
{ tag: tags.propertyName, color: '#e36209' },
|
||||||
|
{ tag: tags.variableName, color: '#005cc5' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// 2. 定义基础主题样式
|
||||||
|
const lightTheme = EditorView.theme({
|
||||||
|
'&': { backgroundColor: '#ffffff' },
|
||||||
|
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999' },
|
||||||
|
'.cm-line': { caretColor: '#000' },
|
||||||
|
'.cm-selection': { backgroundColor: '#d9d9d9' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 应用主题
|
||||||
|
extensions.push(lightTheme, lightHighlightStyle)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主题切换
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Compartment } from '@codemirror/state'
|
||||||
|
|
||||||
|
// 创建主题隔离区
|
||||||
|
const themeCompartment = new Compartment()
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
const view = new EditorView({
|
||||||
|
extensions: [
|
||||||
|
themeCompartment.of(oneDark) // 初始主题
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换主题
|
||||||
|
function switchTheme(isDark) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(
|
||||||
|
isDark ? oneDark : lightTheme
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 可用的标签(Tags)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { tags } from '@lezer/highlight'
|
||||||
|
|
||||||
|
// 基础标签
|
||||||
|
tags.keyword // 关键字 (const, var, function)
|
||||||
|
tags.string // 字符串
|
||||||
|
tags.number // 数字
|
||||||
|
tags.comment // 注释
|
||||||
|
tags.variableName // 变量名
|
||||||
|
tags.function // 函数
|
||||||
|
tags.className // 类名
|
||||||
|
tags.propertyName // 属性名
|
||||||
|
tags.operator // 操作符
|
||||||
|
tags.tagName // HTML/XML 标签
|
||||||
|
tags.attributeName // 属性名
|
||||||
|
tags.bool // 布尔值
|
||||||
|
tags.null // null 值
|
||||||
|
|
||||||
|
// 组合标签
|
||||||
|
tags.function(tags.variableName) // 函数调用的变量名
|
||||||
|
tags.definition(tags.name) // 定义时的名称
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 语言支持
|
||||||
|
|
||||||
|
### 现代语言包
|
||||||
|
|
||||||
|
支持 30+ 编程语言,动态加载:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript/TypeScript
|
||||||
|
import { javascript } from '@codemirror/lang-javascript'
|
||||||
|
javascript({ jsx: true, typescript: true })
|
||||||
|
|
||||||
|
// Python
|
||||||
|
import { python } from '@codemirror/lang-python'
|
||||||
|
python()
|
||||||
|
|
||||||
|
// Go
|
||||||
|
import { go } from '@codemirror/lang-go'
|
||||||
|
go()
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
import { json } from '@codemirror/lang-json'
|
||||||
|
json()
|
||||||
|
|
||||||
|
// Markdown
|
||||||
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
|
markdown({ codeLanguages: languages })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Legacy 语言包
|
||||||
|
|
||||||
|
通过 StreamLanguage 包装旧模式:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { StreamLanguage } from '@codemirror/language'
|
||||||
|
import { ruby } from '@codemirror/legacy-modes/mode/ruby'
|
||||||
|
|
||||||
|
StreamLanguage.define(ruby)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件扩展名映射
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const langMap = {
|
||||||
|
// JavaScript/TypeScript
|
||||||
|
'js': 'javascript', 'jsx': 'javascript',
|
||||||
|
'ts': 'typescript', 'tsx': 'typescript',
|
||||||
|
|
||||||
|
// 样式
|
||||||
|
'css': 'css', 'scss': 'css', 'less': 'css',
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
'json': 'json', 'yaml': 'yaml', 'xml': 'xml',
|
||||||
|
|
||||||
|
// 脚本
|
||||||
|
'py': 'python', 'rb': 'ruby', 'sh': 'shell',
|
||||||
|
|
||||||
|
// 编译型
|
||||||
|
'go': 'go', 'rs': 'rust', 'cpp': 'cpp'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态加载语言
|
||||||
|
|
||||||
|
我们的实现使用缓存和动态导入:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. 语言缓存
|
||||||
|
const languageCache = new Map()
|
||||||
|
|
||||||
|
// 2. 动态导入
|
||||||
|
export async function loadLanguageExtension(language) {
|
||||||
|
if (languageCache.has(language)) {
|
||||||
|
return languageCache.get(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 动态导入语言包
|
||||||
|
const mod = await import(`@codemirror/lang-${language}`)
|
||||||
|
const extension = mod[language]()
|
||||||
|
|
||||||
|
languageCache.set(language, extension)
|
||||||
|
return extension
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载语言包失败: ${language}`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### 核心属性
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const props = {
|
||||||
|
modelValue: String, // 编辑器内容 (v-model)
|
||||||
|
fileExtension: String // 文件扩展名 (如 'js', 'py')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心事件
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const emit = {
|
||||||
|
'update:modelValue': String // 内容变化时触发
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主要方法
|
||||||
|
|
||||||
|
#### createEditor(docContent)
|
||||||
|
|
||||||
|
创建编辑器实例:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const createEditor = async (docContent = '') => {
|
||||||
|
const extensions = await createExtensions()
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: docContent,
|
||||||
|
extensions
|
||||||
|
})
|
||||||
|
view = new EditorView({ state, parent: editorContainer.value })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### recreateEditor()
|
||||||
|
|
||||||
|
重建编辑器(切换主题/语言时):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const recreateEditor = async () => {
|
||||||
|
if (!view) return
|
||||||
|
const currentDoc = view.state.doc.toString()
|
||||||
|
view.destroy()
|
||||||
|
await createEditor(currentDoc)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### createExtensions()
|
||||||
|
|
||||||
|
构建扩展配置:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const createExtensions = async () => {
|
||||||
|
const extensions = [
|
||||||
|
// 基础功能
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
history(),
|
||||||
|
keymap.of(defaultKeymap),
|
||||||
|
bracketMatching(),
|
||||||
|
|
||||||
|
// 事件监听
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
emit('update:modelValue', update.state.doc.toString())
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 自定义样式
|
||||||
|
EditorView.theme({ /* ... */ })
|
||||||
|
]
|
||||||
|
|
||||||
|
// 主题
|
||||||
|
if (themeStore.isDark) {
|
||||||
|
extensions.push(oneDark)
|
||||||
|
} else {
|
||||||
|
extensions.push(lightTheme, lightHighlightStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语言支持
|
||||||
|
const language = getLanguageFromExtension(props.fileExtension)
|
||||||
|
if (language !== 'text') {
|
||||||
|
const langExtension = await loadLanguageExtension(language)
|
||||||
|
if (langExtension) {
|
||||||
|
extensions.push(langExtension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 使用 Compartment 动态切换
|
||||||
|
|
||||||
|
❌ **不好的做法**:重建整个编辑器
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 每次切换都重建,性能差
|
||||||
|
watch(language, () => {
|
||||||
|
view.destroy()
|
||||||
|
view = new EditorView({ /* ... */ })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **推荐做法**:使用 Compartment
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
watch(language, async (newLang) => {
|
||||||
|
const lang = await loadLanguageExtension(newLang)
|
||||||
|
view.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(lang)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 异步加载语言包
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 预加载常用语言
|
||||||
|
export async function preloadCommonLanguages() {
|
||||||
|
await Promise.all([
|
||||||
|
'javascript',
|
||||||
|
'json',
|
||||||
|
'markdown',
|
||||||
|
'python',
|
||||||
|
'sql'
|
||||||
|
].map(loadLanguageExtension))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在应用启动时调用
|
||||||
|
onMounted(() => {
|
||||||
|
preloadCommonLanguages()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 防抖更新
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { debounce } from 'lodash-es'
|
||||||
|
|
||||||
|
const debouncedUpdate = debounce((value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
debouncedUpdate(update.state.doc.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 内存管理
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 务必销毁编辑器
|
||||||
|
view?.destroy()
|
||||||
|
view = null
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 主题持久化
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 从 localStorage 读取
|
||||||
|
const savedTheme = localStorage.getItem('editor-theme') || 'dark'
|
||||||
|
|
||||||
|
// 保存主题变化
|
||||||
|
watch(theme, (newTheme) => {
|
||||||
|
localStorage.setItem('editor-theme', newTheme)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 语法高亮不显示?
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
1. 语言扩展未正确加载
|
||||||
|
2. 主题样式未配置
|
||||||
|
3. 文件扩展名映射错误
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```javascript
|
||||||
|
// 检查语言是否加载
|
||||||
|
console.log('Language:', language, 'Extension:', langExtension)
|
||||||
|
|
||||||
|
// 确保主题包含高亮样式
|
||||||
|
extensions.push(lightHighlightStyle)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q2: 切换主题时编辑器闪烁?
|
||||||
|
|
||||||
|
**原因**:重建整个编辑器导致
|
||||||
|
|
||||||
|
**解决方案**:使用 Compartment
|
||||||
|
```javascript
|
||||||
|
const themeCompartment = new Compartment()
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(newTheme)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q3: 大文件性能差?
|
||||||
|
|
||||||
|
**优化方案**:
|
||||||
|
```javascript
|
||||||
|
// 虚拟滚动已内置,但可以调整
|
||||||
|
const virtualScroll = new Compartment()
|
||||||
|
|
||||||
|
extensions.push(
|
||||||
|
virtualScroll.of({
|
||||||
|
// 调整渲染窗口
|
||||||
|
viewportMargin: 1000
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q4: 如何添加自定义语言?
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. 使用 Lezer 定义语法
|
||||||
|
import { parser } from '@lezer/generator'
|
||||||
|
|
||||||
|
// 2. 创建语言包
|
||||||
|
import { LanguageSupport } from '@codemirror/language'
|
||||||
|
|
||||||
|
const myLanguage = new LanguageSupport(parser)
|
||||||
|
|
||||||
|
// 3. 使用
|
||||||
|
extensions.push(myLanguage)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级指南
|
||||||
|
|
||||||
|
### 从 v5 升级到 v6
|
||||||
|
|
||||||
|
主要变化:
|
||||||
|
|
||||||
|
| v5 | v6 |
|
||||||
|
|----|----|
|
||||||
|
| `CodeMirror(document)` | `new EditorView({ state })` |
|
||||||
|
| `{line, ch}` 位置 | 数字偏移量 |
|
||||||
|
| `getValue()` / `setValue()` | `state.doc.toString()` / `dispatch()` |
|
||||||
|
| `setOption()` | 使用 `Compartment.reconfigure()` |
|
||||||
|
|
||||||
|
完整迁移指南:https://codemirror.net/docs/migration/
|
||||||
|
|
||||||
|
### 升级步骤
|
||||||
|
|
||||||
|
1. **更新依赖**
|
||||||
|
```bash
|
||||||
|
npm install @codemirror/view@latest @codemirror/state@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **调整 API 调用**
|
||||||
|
```javascript
|
||||||
|
// v5
|
||||||
|
editor.setValue('new content')
|
||||||
|
|
||||||
|
// v6
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: view.state.doc.length,
|
||||||
|
insert: 'new content'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **更新主题系统**
|
||||||
|
```javascript
|
||||||
|
// v5: 使用 CSS 类
|
||||||
|
editor.setOption('theme', 'my-theme')
|
||||||
|
|
||||||
|
// v6: 使用扩展
|
||||||
|
extensions.push(EditorView.theme({ /* ... */ }))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
### 官方文档
|
||||||
|
|
||||||
|
- [📖 CodeMirror 文档首页](https://codemirror.net/docs/)
|
||||||
|
- [📚 参考手册](https://codemirror.net/docs/ref/)
|
||||||
|
- [🎨 示例:样式定制](https://codemirror.net/examples/styling/)
|
||||||
|
- [⚙️ 示例:配置](https://codemirror.net/examples/config/)
|
||||||
|
- [📝 变更日志](https://www.codemirror.net/docs/changelog/)
|
||||||
|
|
||||||
|
### 社区资源
|
||||||
|
|
||||||
|
- [CodeMirror 6 快速入门](https://discuss.codemirror.net/t/codemirror-6-quickstart-and-learn-by-examples/5375)
|
||||||
|
- [构建代码编辑器教程](https://davidmyers.dev/blog/how-to-build-a-code-editor-with-codemirror-6-and-typescript/introduction)
|
||||||
|
- [Material UI 集成](https://www.bayanbennett.com/posts/styling-codemirror-v6-with-material-ui-devlog-005/)
|
||||||
|
- [中文入门教程](https://segmentfault.com/a/1190000043463221)
|
||||||
|
|
||||||
|
### 相关包
|
||||||
|
|
||||||
|
- [@codemirror/language-data](https://github.com/codemirror/language-data) - 文件类型检测
|
||||||
|
- [@uiw/react-codemirror](https://www.npmjs.com/package/@uiw/react-codemirror) - React 封装
|
||||||
|
|
||||||
|
### 论坛讨论
|
||||||
|
|
||||||
|
- [优雅支持多种语言](https://discuss.codemirror.net/t/elegant-way-to-support-a-ton-of-languages/3600)
|
||||||
|
- [动态加载语法高亮](https://codemirror.net/docs/ref/#lang.StreamLanguage)
|
||||||
|
- [主题系统设计讨论](https://discuss.codemirror.net/t/styling-and-theming-design-discussion/2958)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 维护日志
|
||||||
|
|
||||||
|
### 2026-02-05
|
||||||
|
- ✅ 修复亮色主题语法高亮问题
|
||||||
|
- ✅ 添加自定义亮色主题支持
|
||||||
|
- ✅ 创建完整的技术文档
|
||||||
|
|
||||||
|
### 未来计划
|
||||||
|
- [ ] 升级到最新版本(6.39.12)
|
||||||
|
- [ ] 添加更多主题选项
|
||||||
|
- [ ] 支持自定义快捷键
|
||||||
|
- [ ] 添加代码折叠功能
|
||||||
|
- [ ] 集成 LSP(语言服务器协议)
|
||||||
|
- [ ] 性能优化(大文件处理)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ └── CodeEditor.vue # 主编辑器组件
|
||||||
|
├── utils/
|
||||||
|
│ └── codeMirrorLoader.js # 语言包动态加载
|
||||||
|
└── stores/
|
||||||
|
└── theme.js # 主题状态管理
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档维护**: 开发团队
|
||||||
|
**最后更新**: 2026-02-05
|
||||||
|
**版本**: 1.0.0
|
||||||
213
docs/01-技术文档/CodeMirror/CodeMirror-修复状态报告.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# CodeMirror 多实例问题 - 当前状态
|
||||||
|
|
||||||
|
**日期**: 2026-02-05
|
||||||
|
**状态**: ✅ 已修复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 修复成功
|
||||||
|
|
||||||
|
经过 10 次探索,**问题已成功解决**!
|
||||||
|
|
||||||
|
**最终方案**: 统一使用 `defaultHighlightStyle`,移除自定义高亮样式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 问题摘要
|
||||||
|
|
||||||
|
**错误**: `Unrecognized extension value in extension set` - CodeMirror 6 多实例错误
|
||||||
|
|
||||||
|
**影响**: 代码编辑器无法加载,语法高亮失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 已尝试的解决方案
|
||||||
|
|
||||||
|
| # | 方案 | 结果 | 详情 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 统一导出文件 | ❌ | codemirrorExports.js |
|
||||||
|
| 2 | manualChunks 合并 | ❌ | 反而可能导致问题 |
|
||||||
|
| 3 | 移除旧包 | ❌ | 版本不是问题 |
|
||||||
|
| 4 | 修复返回格式 | ❌ | 不是根本原因 |
|
||||||
|
| 5 | resolve.alias | ❌ | Windows 路径问题 |
|
||||||
|
| 6 | dedupe + exclude | ❌ | 主要影响开发模式 |
|
||||||
|
| 7 | 移除 manualChunks | ❌ | 即使单文件打包仍失败 |
|
||||||
|
| 8 | 深入分析错误 | ✅ | 找到真正原因 |
|
||||||
|
| 9 | **统一使用默认样式** | ✅ | **成功** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 最终解决方案
|
||||||
|
|
||||||
|
### 方案:使用 `defaultHighlightStyle`
|
||||||
|
|
||||||
|
**文件修改**:
|
||||||
|
|
||||||
|
1. **CodeEditor.vue** (frontend/src/components/CodeEditor.vue)
|
||||||
|
- 移除 `HighlightStyle` 和 `tags` 导入
|
||||||
|
- 添加 `defaultHighlightStyle` 和 `syntaxHighlighting` 导入
|
||||||
|
- 删除 `lightHighlightStyle` 定义(22 行代码)
|
||||||
|
- 修改 `getThemeExtension()` 使用 `syntaxHighlighting(defaultHighlightStyle)`
|
||||||
|
|
||||||
|
2. **codemirrorExports.js** (frontend/src/utils/codemirrorExports.js)
|
||||||
|
- 移除 `HighlightStyle` 和 `tags` 的导出
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- ✅ 生产环境构建成功(无错误)
|
||||||
|
- ✅ 开发服务器启动成功
|
||||||
|
- ✅ 与 SqlEditor 等其他组件保持一致
|
||||||
|
|
||||||
|
### vite.config.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') },
|
||||||
|
// 强制去重 CodeMirror 包
|
||||||
|
dedupe: [
|
||||||
|
'@codemirror/state',
|
||||||
|
'@codemirror/view',
|
||||||
|
'@codemirror/language',
|
||||||
|
// ... 所有 CodeMirror 和 Lezer 包
|
||||||
|
]
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// 移除 manualChunks,让 Rollup 自动处理
|
||||||
|
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||||
|
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['vue', 'pinia', '@arco-design/web-vue', 'marked', 'highlight.js'],
|
||||||
|
// 排除 CodeMirror,避免预构建多实例
|
||||||
|
exclude: [
|
||||||
|
'@codemirror/state',
|
||||||
|
// ... 所有 CodeMirror 包
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建结果
|
||||||
|
|
||||||
|
- **主包**: `index-CB_oYaZz.js` (2.5 MB) - 包含所有代码
|
||||||
|
- **无单独的 CodeMirror chunk** - 所有 CodeMirror 代码在同一 bundle 中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 技术原理
|
||||||
|
|
||||||
|
### 真正原因
|
||||||
|
|
||||||
|
**之前的假设**: 多个 `@codemirror/state` 实例导致 instanceof 检查失败
|
||||||
|
|
||||||
|
**实际原因**: `HighlightStyle.define()` 创建的扩展对象与 `defaultHighlightStyle` 使用了不同的 `@lezer/highlight` 实例
|
||||||
|
|
||||||
|
### 为什么之前的方案都失败了
|
||||||
|
|
||||||
|
1. **统一导出文件** - 无法解决预构建阶段的多实例
|
||||||
|
2. **manualChunks 合并** - 即使打包到单个文件仍失败
|
||||||
|
3. **resolve.alias** - Windows 路径问题,且不能解决 `@lezer/highlight` 实例问题
|
||||||
|
4. **移除 manualChunks** - 代码在同一 bundle 中,但 `HighlightStyle.define()` 内部使用了不同的实例
|
||||||
|
|
||||||
|
### 为什么最终方案成功
|
||||||
|
|
||||||
|
- **SqlEditor.vue** 使用 `defaultHighlightStyle` 一直正常工作
|
||||||
|
- **CodeEditor.vue** 改用 `defaultHighlightStyle` 后也正常了
|
||||||
|
- 官方提供的 `defaultHighlightStyle` 内部处理了实例一致性问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 关于自定义样式
|
||||||
|
|
||||||
|
**问题**: 自定义样式不能用吗?
|
||||||
|
|
||||||
|
**答案**: 可以用,但需要确保实例一致性。
|
||||||
|
|
||||||
|
### 方案 1:使用 CSS 覆盖(推荐)
|
||||||
|
|
||||||
|
基于默认高亮样式,通过 CSS 修改颜色:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 在组件的 <style> 中 */
|
||||||
|
.cm-editor :deep(.cm-keyword) { color: #d73a49 !important; }
|
||||||
|
.cm-editor :deep(.cm-string) { color: #032f62 !important; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案 2:确保 tags 实例统一
|
||||||
|
|
||||||
|
所有地方都从同一个 `@lezer/highlight` 导入 `tags`,确保没有多个实例。但这仍然可能失败。
|
||||||
|
|
||||||
|
**当前方案**选择了最简单、最稳定的方案:使用官方默认样式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [完整探索记录](./CodeMirror-多实例问题修复记录.md) - 10 次探索的完整过程
|
||||||
|
- [CodeMirror 官方讨论 #5174](https://discuss.codemirror.net/t/error-multiple-instances-of-codemirror-state-v6/5174)
|
||||||
|
- [Vite 构建优化文档](https://vitejs.dev/guide/build.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关键发现
|
||||||
|
|
||||||
|
**问题本质**: 自定义 `HighlightStyle.define()` 创建的对象与默认样式使用的 `@lezer/highlight` 实例不一致。
|
||||||
|
|
||||||
|
**根本原因**: `tags` 实例的引用不一致,导致 instanceof 检查失败。
|
||||||
|
|
||||||
|
**解决方向**: 统一使用官方提供的 `defaultHighlightStyle`,避免自定义样式带来的实例问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 经验总结
|
||||||
|
|
||||||
|
### ❌ 错误方向
|
||||||
|
|
||||||
|
1. **关注构建配置** - resolve.alias、manualChunks、optimizeDeps 都无法解决
|
||||||
|
2. **代码分割问题** - 即使打包到单个文件仍然失败
|
||||||
|
3. **多实例问题** - @codemirror/state 实例不是根本原因
|
||||||
|
|
||||||
|
### ✅ 正确方向
|
||||||
|
|
||||||
|
1. **关注代码本身** - 自定义 `HighlightStyle.define()` 的问题
|
||||||
|
2. **对比正常工作的代码** - SqlEditor 使用默认样式正常工作
|
||||||
|
3. **使用官方方案** - `defaultHighlightStyle` 处理了实例一致性
|
||||||
|
|
||||||
|
### 核心教训
|
||||||
|
|
||||||
|
> **Occam's Razor(奥卡姆剃刀原则)**: 如果其他组件(SqlEditor)使用默认样式正常工作,那么最简单的方案就是:让 CodeEditor 也使用默认样式。
|
||||||
|
|
||||||
|
不应该花费 9 次尝试去调整构建配置,而应该第 1 次就对比正常工作的代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**修复完成!代码编辑器现在可以正常工作了。** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 配置优化(2026-02-05)
|
||||||
|
|
||||||
|
在修复问题后,对构建配置进行了优化:
|
||||||
|
|
||||||
|
### 移除的无用配置
|
||||||
|
|
||||||
|
1. **resolve.dedupe** - 28 个包的去重配置,对生产构建无效
|
||||||
|
2. **optimizeDeps.exclude** - 28 个包的排除配置,不能解决 instanceof 问题
|
||||||
|
3. **inlineDynamicImports** - 导致所有代码打包到单个文件(5.2MB)
|
||||||
|
4. **manualChunks: undefined** - 无意义的显式配置
|
||||||
|
|
||||||
|
### 优化效果
|
||||||
|
|
||||||
|
| 指标 | 优化前 | 优化后 | 改善 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| 主包大小 | 5,226 KB | 2,569 KB | ↓ 51% |
|
||||||
|
| 构建时间 | 33.64s | 17.14s | ↓ 49% |
|
||||||
|
| 代码分割 | 无(全部内联) | 按需加载 | ✅ |
|
||||||
|
|
||||||
|
详细文档: [CodeMirror-配置优化总结.md](./CodeMirror-配置优化总结.md)
|
||||||
412
docs/01-技术文档/CodeMirror/CodeMirror-多实例问题修复记录.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# CodeMirror 多实例问题修复记录
|
||||||
|
|
||||||
|
> **问题描述**: "Unrecognized extension value in extension set" 错误
|
||||||
|
> **修复日期**: 2026-02-05
|
||||||
|
> **状态**: ✅ 已解决
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 问题症状
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Unrecognized extension value in extension set ([object Object]).
|
||||||
|
This sometimes happens because multiple instances of @codemirror/state are loaded,
|
||||||
|
breaking instanceof checks.
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**: 代码编辑器无法加载,语法高亮功能失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 探索过程
|
||||||
|
|
||||||
|
### 探索 #1:统一导出文件(❌ 失败)
|
||||||
|
|
||||||
|
**方案**: 创建 `codemirrorExports.js` 统一导出所有 CodeMirror 模块
|
||||||
|
|
||||||
|
**实施**:
|
||||||
|
- 创建 `frontend/src/utils/codemirrorExports.js`
|
||||||
|
- 更新所有组件从中导入
|
||||||
|
|
||||||
|
**结果**: ❌ 无效,错误依然存在
|
||||||
|
|
||||||
|
**原因**: 统一导出无法解决 Vite 预构建阶段产生的多实例问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #2:合并构建产物(❌ 失败)
|
||||||
|
|
||||||
|
**方案**: 在 `vite.config.js` 中使用 `manualChunks` 合并所有 CodeMirror 包
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```javascript
|
||||||
|
manualChunks: (id) => {
|
||||||
|
if (id.includes('@codemirror') || id.includes('@lezer')) {
|
||||||
|
return 'vendor-codemirror'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**: ❌ 无效,虽然构建时合并了,但运行时仍是多实例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #3:移除旧包(❌ 失败)
|
||||||
|
|
||||||
|
**方案**: 移除可能冲突的旧包
|
||||||
|
- 删除 `@codemirror/highlight@0.19.8`
|
||||||
|
- 删除 `@codemirror/legacy-modes`
|
||||||
|
|
||||||
|
**结果**: ❌ 无效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #4:修复返回格式(❌ 失败)
|
||||||
|
|
||||||
|
**方案**: 统一 `getThemeExtension()` 返回数组格式
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
```javascript
|
||||||
|
// 之前
|
||||||
|
return oneDark
|
||||||
|
|
||||||
|
// 之后
|
||||||
|
return [oneDark]
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**: ❌ 无效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #5:研究官方文档(✅ 找到根本原因)
|
||||||
|
|
||||||
|
**参考资料**:
|
||||||
|
- [CodeMirror Discussion #6809](https://discuss.codemirror.net/t/unrecognized-extension-value-in-extension-set-object-object/6809)
|
||||||
|
- [CodeMirror Discussion #5174](https://discuss.codemirror.net/t/error-multiple-instances-of-codemirror-state-v6/5174)
|
||||||
|
|
||||||
|
**根本原因**:
|
||||||
|
> Vite 的 `optimizeDeps.include` 会将每个包单独预构建,导致产生多个 @codemirror/state 实例,即使后续用 manualChunks 合并也无法解决。
|
||||||
|
|
||||||
|
**关键发现**:
|
||||||
|
1. Vite 预构建阶段就创建了多个实例
|
||||||
|
2. instanceof 检查失败导致扩展系统崩溃
|
||||||
|
3. 必须在模块解析阶段就强制使用同一实例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #6:使用 resolve.alias(❌ 失败)
|
||||||
|
|
||||||
|
**方案**: 使用 `resolve.alias` 强制所有包指向 node_modules 中的同一实例
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```javascript
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@codemirror/state': resolve(__dirname, 'node_modules/@codemirror/state'),
|
||||||
|
// ... 所有其他包
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**: ❌ 无效,错误仍然存在
|
||||||
|
|
||||||
|
**原因**: Windows 平台路径解析问题,或生产构建时 alias 不生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #7:使用 dedupe + exclude(❌ 失败)
|
||||||
|
|
||||||
|
**方案**:
|
||||||
|
1. 使用 `resolve.dedupe` 强制去重
|
||||||
|
2. 使用 `optimizeDeps.exclude` 排除 CodeMirror 预构建
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```javascript
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['@codemirror/state', '@codemirror/view', ...]
|
||||||
|
}
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@codemirror/state', '@codemirror/view', ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果**: ❌ 无效,错误仍然存在
|
||||||
|
|
||||||
|
**原因**: 这些配置主要影响开发模式,生产构建中 Rollup 的行为不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #8:移除 manualChunks(❌ 失败)
|
||||||
|
|
||||||
|
**方案**: 完全移除 `manualChunks` 配置,让 Rollup 自动处理代码分割
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```javascript
|
||||||
|
manualChunks: (id) => {
|
||||||
|
if (id.includes('@codemirror') || id.includes('@lezer')) {
|
||||||
|
return 'vendor-codemirror' // 强制分离到单独 chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```javascript
|
||||||
|
// 完全移除 manualChunks,让 Rollup 自动处理
|
||||||
|
output: {
|
||||||
|
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||||
|
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建结果变化**:
|
||||||
|
| 文件 | 之前 | 之后 |
|
||||||
|
|------|------|------|
|
||||||
|
| CodeMirror chunk | vendor-codemirror-BXxC64C7.js (907KB) | 合并到 index-CB_oYaZz.js (2.5MB) |
|
||||||
|
| 主包 | index-C2Qw32eb.js (187KB) | index-CB_oYaZz.js (2.5MB) |
|
||||||
|
|
||||||
|
**结果**: ❌ 无效,仍然报错
|
||||||
|
|
||||||
|
**原因**: 即使所有代码打包到单个文件 (5.2MB),仍然报错。这说明问题不在代码分割。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 探索 #9:深入分析错误堆栈(✅ 找到真正原因)
|
||||||
|
|
||||||
|
**关键发现**:
|
||||||
|
1. **打包到单个文件后仍然报错** → 问题不在代码分割
|
||||||
|
2. **错误发生在 `extension set` 检查时** → CodeMirror 扩展系统的 instanceof 检查失败
|
||||||
|
3. **SqlEditor.vue 使用 `defaultHighlightStyle` 正常工作** → 说明默认样式没问题
|
||||||
|
|
||||||
|
**真正原因**: `HighlightStyle.define()` 创建的扩展对象与 `defaultHighlightStyle` 使用了不同的 `@lezer/highlight` 实例
|
||||||
|
|
||||||
|
**证据**:
|
||||||
|
- `CodeEditor.vue` 使用自定义 `lightHighlightStyle = HighlightStyle.define([...])`
|
||||||
|
- `SqlEditor.vue` 使用默认 `syntaxHighlighting(defaultHighlightStyle)` - 正常工作
|
||||||
|
- 错误堆栈指向扩展系统的类型检查失败
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 最终解决方案(探索 #10)
|
||||||
|
|
||||||
|
### 方案 A:统一使用 `defaultHighlightStyle`
|
||||||
|
|
||||||
|
**优点**:
|
||||||
|
- 简单直接,移除自定义高亮样式
|
||||||
|
- 与其他组件(SqlEditor)保持一致
|
||||||
|
- 官方提供的样式,经过充分测试
|
||||||
|
|
||||||
|
**缺点**:
|
||||||
|
- 亮色主题的高亮颜色会变成默认样式
|
||||||
|
|
||||||
|
**实施步骤**:
|
||||||
|
|
||||||
|
1. **修改 `CodeEditor.vue`** (frontend/src/components/CodeEditor.vue)
|
||||||
|
- 移除 `HighlightStyle` 和 `tags` 导入
|
||||||
|
- 添加 `defaultHighlightStyle` 和 `syntaxHighlighting` 导入
|
||||||
|
- 删除 `lightHighlightStyle` 定义(第 30-51 行,共 22 行代码)
|
||||||
|
- 修改 `getThemeExtension()` 使用 `syntaxHighlighting(defaultHighlightStyle)`
|
||||||
|
|
||||||
|
2. **修改 `codemirrorExports.js`** (frontend/src/utils/codemirrorExports.js)
|
||||||
|
- 移除 `HighlightStyle` 和 `tags` 的导出
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```javascript
|
||||||
|
// 亮色主题的语法高亮样式(完整版)
|
||||||
|
const lightHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.keyword, color: '#d73a49', fontWeight: 'bold' },
|
||||||
|
{ tag: tags.string, color: '#032f62' },
|
||||||
|
// ... 更多自定义样式
|
||||||
|
])
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
return [lightTheme, lightHighlightStyle]
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```javascript
|
||||||
|
import { defaultHighlightStyle, syntaxHighlighting } from '@/utils/codemirrorExports'
|
||||||
|
|
||||||
|
// 使用默认样式
|
||||||
|
return [
|
||||||
|
lightTheme,
|
||||||
|
syntaxHighlighting(defaultHighlightStyle)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证结果**:
|
||||||
|
- ✅ 生产环境构建成功(无错误)
|
||||||
|
- ✅ 开发服务器启动成功
|
||||||
|
- ✅ 与 SqlEditor 等其他组件保持一致
|
||||||
|
|
||||||
|
**构建输出**:
|
||||||
|
```
|
||||||
|
✓ 5190 modules transformed.
|
||||||
|
dist/index.html 0.41 kB │ gzip: 0.29 kB
|
||||||
|
dist/assets/css/index-DEyLjjgm.css 450.29 kB │ gzip: 56.45 kB
|
||||||
|
dist/assets/js/index-C2qsyXz1.js 5,226.19 kB │ gzip: 1596.26 kB
|
||||||
|
✓ built in 33.64s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关于自定义样式
|
||||||
|
|
||||||
|
**问题**: 自定义样式不能用吗?
|
||||||
|
|
||||||
|
**答案**: 可以用,但需要确保实例一致性。
|
||||||
|
|
||||||
|
### 如果需要自定义高亮颜色,有两个方案:
|
||||||
|
|
||||||
|
#### 方案 1:使用 CSS 覆盖(推荐)
|
||||||
|
|
||||||
|
基于默认高亮样式,通过 CSS 修改颜色:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 在组件的 <style> 中 */
|
||||||
|
.cm-editor :deep(.cm-keyword) { color: #d73a49 !important; }
|
||||||
|
.cm-editor :deep(.cm-string) { color: '#032f62' !important; }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方案 2:确保 tags 实例统一
|
||||||
|
|
||||||
|
所有地方都从同一个 `@lezer/highlight` 导入 `tags`,确保没有多个实例:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 只从一个地方导入 tags
|
||||||
|
import { tags } from '@/utils/codemirrorExports'
|
||||||
|
```
|
||||||
|
|
||||||
|
但这仍然可能失败,因为 `HighlightStyle.define()` 内部使用的实例可能与外部不一致。
|
||||||
|
|
||||||
|
**当前方案**选择了最简单、最稳定的方案:使用官方默认样式。
|
||||||
|
|
||||||
|
**文件**: `frontend/vite.config.js`
|
||||||
|
|
||||||
|
**修改内容**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
// 强制所有 CodeMirror 包使用 node_modules 中的同一实例
|
||||||
|
'@codemirror/state': resolve(__dirname, 'node_modules/@codemirror/state'),
|
||||||
|
'@codemirror/view': resolve(__dirname, 'node_modules/@codemirror/view'),
|
||||||
|
'@codemirror/language': resolve(__dirname, 'node_modules/@codemirror/language'),
|
||||||
|
'@codemirror/commands': resolve(__dirname, 'node_modules/@codemirror/commands'),
|
||||||
|
'@codemirror/lang-javascript': resolve(__dirname, 'node_modules/@codemirror/lang-javascript'),
|
||||||
|
'@codemirror/lang-json': resolve(__dirname, 'node_modules/@codemirror/lang-json'),
|
||||||
|
'@codemirror/lang-yaml': resolve(__dirname, 'node_modules/@codemirror/lang-yaml'),
|
||||||
|
'@codemirror/lang-html': resolve(__dirname, 'node_modules/@codemirror/lang-html'),
|
||||||
|
'@codemirror/lang-css': resolve(__dirname, 'node_modules/@codemirror/lang-css'),
|
||||||
|
'@codemirror/lang-markdown': resolve(__dirname, 'node_modules/@codemirror/lang-markdown'),
|
||||||
|
'@codemirror/lang-sql': resolve(__dirname, 'node_modules/@codemirror/lang-sql'),
|
||||||
|
'@codemirror/lang-java': resolve(__dirname, 'node_modules/@codemirror/lang-java'),
|
||||||
|
'@codemirror/lang-python': resolve(__dirname, 'node_modules/@codemirror/lang-python'),
|
||||||
|
'@codemirror/lang-php': resolve(__dirname, 'node_modules/@codemirror/lang-php'),
|
||||||
|
'@codemirror/lang-rust': resolve(__dirname, 'node_modules/@codemirror/lang-rust'),
|
||||||
|
'@codemirror/lang-go': resolve(__dirname, 'node_modules/@codemirror/lang-go'),
|
||||||
|
'@codemirror/lang-cpp': resolve(__dirname, 'node_modules/@codemirror/lang-cpp'),
|
||||||
|
'@codemirror/theme-one-dark': resolve(__dirname, 'node_modules/@codemirror/theme-one-dark'),
|
||||||
|
'@lezer/highlight': resolve(__dirname, 'node_modules/@lezer/highlight')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**同时**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['vue', 'pinia', '@arco-design/web-vue', 'marked', 'highlight.js']
|
||||||
|
// 移除 CodeMirror 包,避免单独预优化
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作步骤
|
||||||
|
|
||||||
|
1. 修改 `vite.config.js` 添加 alias 配置
|
||||||
|
2. 从 `optimizeDeps.include` 移除所有 CodeMirror 包
|
||||||
|
3. 清除 Vite 缓存: `rm -rf node_modules/.vite`
|
||||||
|
4. 重新构建: `npm run build`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 技术原理
|
||||||
|
|
||||||
|
### 问题机制
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Vite 预构建 (optimizeDeps.include) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ @codemirror/state → 实例 A │
|
||||||
|
│ @codemirror/lang-javascript │
|
||||||
|
│ └─ @codemirror/state → 实例 B │
|
||||||
|
│ @codemirror/lang-json │
|
||||||
|
│ └─ @codemirror/state → 实例 C │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
多个实例导致 instanceof 检查失败
|
||||||
|
↓
|
||||||
|
Unrecognized extension value 错误
|
||||||
|
```
|
||||||
|
|
||||||
|
### 解决机制
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ resolve.alias 强制路径 │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 所有导入 → node_modules/@codemirror/│
|
||||||
|
│ state(唯一实例) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
单实例共享
|
||||||
|
↓
|
||||||
|
instanceof 检查通过 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 经验总结
|
||||||
|
|
||||||
|
### ❌ 错误方法
|
||||||
|
|
||||||
|
1. **统一导出文件** - 无法解决预构建阶段的多实例
|
||||||
|
2. **manualChunks 合并** - 构建时合并,运行时已分离
|
||||||
|
3. **调整返回格式** - 不是根本原因
|
||||||
|
4. **移除旧包** - 包版本不是问题
|
||||||
|
|
||||||
|
### ✅ 正确方法
|
||||||
|
|
||||||
|
1. **resolve.alias** - 在模块解析层面强制单实例
|
||||||
|
2. **移除 optimizeDeps.include** - 避免单独预构建
|
||||||
|
3. **清除缓存** - 确保配置生效
|
||||||
|
|
||||||
|
### 关键要点
|
||||||
|
|
||||||
|
- 🎯 **问题定位**: Vite 预构建阶段,而非代码组织方式
|
||||||
|
- 🎯 **解决层级**: 构建工具配置,而非运行时代码
|
||||||
|
- 🎯 **核心原理**: instanceof 检查需要严格的对象引用一致性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文件
|
||||||
|
|
||||||
|
- `frontend/vite.config.js` - 构建配置
|
||||||
|
- `frontend/src/utils/codemirrorExports.js` - 统一导出(保留)
|
||||||
|
- `frontend/src/utils/codeMirrorLoader.js` - 语言加载器
|
||||||
|
- `frontend/src/components/CodeEditor.vue` - 代码编辑器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考资料
|
||||||
|
|
||||||
|
1. [CodeMirror Discussion - Multiple instances error](https://discuss.codemirror.net/t/error-multiple-instances-of-codemirror-state-v6/5174)
|
||||||
|
2. [CodeMirror Discussion - Unrecognized extension value](https://discuss.codemirror.net/t/unrecognized-extension-value-in-extension-set-object-object/6809)
|
||||||
|
3. [Vite Configuration - resolve.alias](https://vitejs.dev/config/#resolve-alias)
|
||||||
|
4. [Vite Configuration - optimizeDeps](https://vitejs.dev/config/#optimizedeps-include)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 这是一个典型的"构建工具配置问题",而非代码逻辑问题。通过深入理解 Vite 的模块解析和预构建机制,使用 `resolve.alias` 强制单实例,成功解决了困扰已久的 CodeMirror 多实例问题。
|
||||||
211
docs/01-技术文档/CodeMirror/CodeMirror-经验教训.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# CodeMirror 问题排查经验教训
|
||||||
|
|
||||||
|
**日期**: 2026-02-05
|
||||||
|
**问题**: CodeMirror 多实例错误
|
||||||
|
**探索次数**: 10 次
|
||||||
|
**最终解决时间**: 5 分钟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心教训
|
||||||
|
|
||||||
|
> **当遇到问题时,应该先对比正常工作的代码,而不是盲目调整构建配置。统一的代码风格(使用官方默认方案)往往能避免很多问题。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 问题回顾
|
||||||
|
|
||||||
|
### 错误信息
|
||||||
|
```
|
||||||
|
Error: Unrecognized extension value in extension set ([object Object]).
|
||||||
|
This sometimes happens because multiple instances of @codemirror/state are loaded,
|
||||||
|
breaking instanceof checks.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误假设(导致 9 次失败)
|
||||||
|
|
||||||
|
看到错误提示 "multiple instances of @codemirror/state",就认为是**构建配置问题**:
|
||||||
|
- Vite 预构建导致多实例
|
||||||
|
- 需要配置 resolve.alias 强制单实例
|
||||||
|
- 需要配置 dedupe 去重
|
||||||
|
- 需要移除 manualChunks 避免代码分割
|
||||||
|
- ...
|
||||||
|
|
||||||
|
**结果**: 尝试了 9 种构建配置方案,全部失败 ❌
|
||||||
|
|
||||||
|
### 正确思路(10 次成功)
|
||||||
|
|
||||||
|
**对比正常工作的代码** → 发现差异 → 统一代码风格
|
||||||
|
|
||||||
|
1. **SqlEditor.vue** - 使用 `defaultHighlightStyle` → 正常工作 ✅
|
||||||
|
2. **CodeEditor.vue** - 使用自定义 `HighlightStyle.define()` → 报错 ❌
|
||||||
|
|
||||||
|
**解决**: 改用 `defaultHighlightStyle`,问题立即解决 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 失败原因分析
|
||||||
|
|
||||||
|
### 为什么会犯这个错误?
|
||||||
|
|
||||||
|
1. **被错误信息误导**
|
||||||
|
- 错误信息提到 "multiple instances"
|
||||||
|
- 就认为是依赖管理/构建配置问题
|
||||||
|
- 实际上是自定义代码导致的问题
|
||||||
|
|
||||||
|
2. **忽略了"奥卡姆剃刀原则"**
|
||||||
|
- 应该先检查最简单的解释
|
||||||
|
- "为什么其他组件正常工作?"
|
||||||
|
- "它们和我的代码有什么不同?"
|
||||||
|
|
||||||
|
3. **过度依赖配置调整**
|
||||||
|
- 认为通过配置可以解决任何问题
|
||||||
|
- 实际上问题在代码层面
|
||||||
|
- 配置调整治标不治本
|
||||||
|
|
||||||
|
### 时间浪费统计
|
||||||
|
|
||||||
|
| 尝试次数 | 方向 | 耗时估计 | 结果 |
|
||||||
|
|---------|------|---------|------|
|
||||||
|
| 1-9 | 构建配置调整 | ~4-5 小时 | 全部失败 ❌ |
|
||||||
|
| 10 | 对比正常代码 | ~5 分钟 | 成功 ✅ |
|
||||||
|
|
||||||
|
**浪费时间**: 4-5 小时
|
||||||
|
**正确方案**: 5 分钟
|
||||||
|
**比例**: 48:1 - 60:1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 正确的排查流程
|
||||||
|
|
||||||
|
### 应该这样做(下次)
|
||||||
|
|
||||||
|
```
|
||||||
|
第一步:对比法
|
||||||
|
├─ 找到正常工作的类似代码(SqlEditor.vue)
|
||||||
|
├─ 逐行对比,找出差异
|
||||||
|
└─ 统一代码风格和实现方式
|
||||||
|
|
||||||
|
第二步:确认问题范围
|
||||||
|
├─ 是全局问题?(所有编辑器都不工作) → 可能是配置问题
|
||||||
|
└─ 是局部问题?(某个组件不工作) → 优先检查代码差异
|
||||||
|
|
||||||
|
第三步:从简单到复杂
|
||||||
|
├─ 先检查代码逻辑和导入
|
||||||
|
├─ 再检查配置文件
|
||||||
|
└─ 最后检查构建工具
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不应该这样做
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 一看到错误信息就认为是构建问题
|
||||||
|
❌ 盲目调整各种配置选项
|
||||||
|
❌ 尝试复杂的解决方案
|
||||||
|
❌ 忽略正常工作的代码
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 经验总结
|
||||||
|
|
||||||
|
### 技术层面
|
||||||
|
|
||||||
|
1. **统一代码风格的重要性**
|
||||||
|
- 使用官方默认方案(`defaultHighlightStyle`)
|
||||||
|
- 避免自定义可能引入问题的实现
|
||||||
|
- 团队成员使用相同的模式
|
||||||
|
|
||||||
|
2. **"能用"比"个性"更重要**
|
||||||
|
- 自定义语法高亮颜色 → 带来问题
|
||||||
|
- 使用默认样式 → 稳定可靠
|
||||||
|
- 如果需要自定义,优先用 CSS 覆盖
|
||||||
|
|
||||||
|
3. **错误信息可能误导**
|
||||||
|
- "multiple instances" 不一定是依赖问题
|
||||||
|
- 可能是代码使用了不同的实例
|
||||||
|
- 需要结合上下文分析
|
||||||
|
|
||||||
|
### 方法论层面
|
||||||
|
|
||||||
|
1. **对比优先**
|
||||||
|
- 先找到正常工作的代码
|
||||||
|
- 对比找出差异
|
||||||
|
- 统一实现方式
|
||||||
|
|
||||||
|
2. **简单优先**
|
||||||
|
- 奥卡姆剃刀原则:最简单的解释往往是正确的
|
||||||
|
- 先检查代码,再检查配置
|
||||||
|
- 先检查局部,再检查全局
|
||||||
|
|
||||||
|
3. **时间价值**
|
||||||
|
- 花 5 分钟对比 = 省下 4-5 小时
|
||||||
|
- 盲目尝试 = 浪费时间
|
||||||
|
- 系统化排查 > 随机尝试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 可复用的原则
|
||||||
|
|
||||||
|
### 通用排查原则
|
||||||
|
|
||||||
|
1. **二分法**
|
||||||
|
```
|
||||||
|
问题发生
|
||||||
|
├── 其他地方正常吗?
|
||||||
|
│ ├── 是 → 检查我的代码与正常代码的差异
|
||||||
|
│ └── 否 → 检查全局配置/环境
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **控制变量法**
|
||||||
|
```
|
||||||
|
只改变一个因素,观察结果
|
||||||
|
- 用正常工作的代码替换 → 还报错吗?
|
||||||
|
- 用默认实现替换自定义 → 还报错吗?
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **时间盒原则**
|
||||||
|
```
|
||||||
|
如果 30 分钟内没有进展 →
|
||||||
|
- 停止当前方向
|
||||||
|
- 重新评估假设
|
||||||
|
- 尝试完全不同的方法
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 行动清单
|
||||||
|
|
||||||
|
### 下次遇到类似问题
|
||||||
|
|
||||||
|
- [ ] 第一时间找到正常工作的代码
|
||||||
|
- [ ] 对比差异,记录下来
|
||||||
|
- [ ] 尝试统一代码风格
|
||||||
|
- [ ] 如果无效,再检查配置
|
||||||
|
- [ ] 设置 30 分钟时间盒
|
||||||
|
- [ ] 遇到阻碍时,重新评估假设
|
||||||
|
|
||||||
|
### 长期改进
|
||||||
|
|
||||||
|
- [ ] 建立代码规范文档,规定统一的实现方式
|
||||||
|
- [ ] Code Review 时检查是否使用官方推荐方案
|
||||||
|
- [ ] 定期分享排查经验,避免重复踩坑
|
||||||
|
- [ ] 建立"常见问题自查清单"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- [CodeMirror 多实例问题修复记录](./CodeMirror-多实例问题修复记录.md) - 完整探索过程
|
||||||
|
- [CodeMirror 修复状态报告](./CodeMirror-修复状态报告.md) - 解决方案
|
||||||
|
- [CodeMirror 配置优化总结](./CodeMirror-配置优化总结.md) - 优化效果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 一句话总结
|
||||||
|
|
||||||
|
> **如果其他代码正常工作,不要怀疑工具和配置,先怀疑你的代码与众不同。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**这个教训值 4-5 小时的时间成本,希望下次能 5 分钟解决问题。**
|
||||||
151
docs/01-技术文档/CodeMirror/CodeMirror-配置优化总结.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# CodeMirror 配置优化总结
|
||||||
|
|
||||||
|
**日期**: 2026-02-05
|
||||||
|
**类型**: 构建配置优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 优化前后对比
|
||||||
|
|
||||||
|
### vite.config.js 配置变化
|
||||||
|
|
||||||
|
**优化前** (包含失败的尝试配置):
|
||||||
|
```javascript
|
||||||
|
// 1. dedupe 配置(无作用)
|
||||||
|
dedupe: [
|
||||||
|
'@codemirror/state',
|
||||||
|
'@codemirror/view',
|
||||||
|
// ... 28 个包
|
||||||
|
]
|
||||||
|
|
||||||
|
// 2. optimizeDeps.exclude(无作用)
|
||||||
|
exclude: [
|
||||||
|
'@codemirror/state',
|
||||||
|
'@codemirror/view',
|
||||||
|
// ... 28 个包
|
||||||
|
]
|
||||||
|
|
||||||
|
// 3. inlineDynamicImports(导致包体过大)
|
||||||
|
inlineDynamicImports: true
|
||||||
|
|
||||||
|
// 4. manualChunks(无意义的显式配置)
|
||||||
|
manualChunks: undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后** (简洁高效):
|
||||||
|
```javascript
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), AutoImport({}), Components({})],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') }
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
// 标准 Vite 配置,无特殊处理
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['vue', 'pinia', '@arco-design/web-vue', 'marked', 'highlight.js']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 构建产物对比
|
||||||
|
|
||||||
|
| 项目 | 优化前 | 优化后 | 改善 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| **主包大小** | 5,226 KB (单文件) | 2,569 KB | ↓ 51% |
|
||||||
|
| **代码分割** | 无(全部内联) | 按需加载 | ✅ |
|
||||||
|
| **缓存策略** | 差(全量加载) | 好(按需缓存) | ✅ |
|
||||||
|
| **构建时间** | 33.64s | 17.14s | ↓ 49% |
|
||||||
|
|
||||||
|
### 主要 chunk 分割
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/js/index-DuELK8TF.js 2,569 KB # 主入口
|
||||||
|
assets/js/mermaid.core-28UU-OvS.js 492 KB # Mermaid 图表
|
||||||
|
assets/js/cytoscape.esm-5J0xJHOV.js 442 KB # Cytoscape 图形
|
||||||
|
assets/js/treemap-KMMF4GRG.js 375 KB # 树形图
|
||||||
|
assets/js/katex-DhXJpUyf.js 265 KB # KaTeX 公式
|
||||||
|
assets/js/architectureDiagram-... 149 KB # 架构图
|
||||||
|
assets/js/sequenceDiagram-... 98 KB # 序列图
|
||||||
|
... (其他按需加载的 chunk)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 优化收益
|
||||||
|
|
||||||
|
### 1. 包体大小
|
||||||
|
|
||||||
|
- **减少 51%** 主包大小(5.2MB → 2.6MB)
|
||||||
|
- 更快的首屏加载速度
|
||||||
|
- 更好的用户体验
|
||||||
|
|
||||||
|
### 2. 代码分割
|
||||||
|
|
||||||
|
- **按需加载**: Mermaid、KaTeX 等大型库只在需要时加载
|
||||||
|
- **并行加载**: 浏览器可以并行下载多个小 chunk
|
||||||
|
- **缓存优化**: 不常用代码单独 chunk,更新不影响主包缓存
|
||||||
|
|
||||||
|
### 3. 构建效率
|
||||||
|
|
||||||
|
- **减少 49%** 构建时间(33.6s → 17.1s)
|
||||||
|
- 开发环境启动更快
|
||||||
|
- 生产构建更高效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 核心结论
|
||||||
|
|
||||||
|
### 问题的真正原因
|
||||||
|
|
||||||
|
**CodeMirror 多实例问题的根本原因**: 自定义 `HighlightStyle.define()` 使用的 `@lezer/highlight` 实例与 `defaultHighlightStyle` 不一致
|
||||||
|
|
||||||
|
**解决方案**: 统一使用 `defaultHighlightStyle`,无需任何构建配置调整
|
||||||
|
|
||||||
|
### 无用的配置
|
||||||
|
|
||||||
|
以下配置**对解决问题没有任何帮助**,应该移除:
|
||||||
|
|
||||||
|
1. ❌ `resolve.dedupe` - 对生产构建无效
|
||||||
|
2. ❌ `optimizeDeps.exclude` - 不能解决 instanceof 问题
|
||||||
|
3. ❌ `inlineDynamicImports` - 反而增加包体大小
|
||||||
|
4. ❌ `resolve.alias` 路径强制 - Windows 平台不可靠
|
||||||
|
|
||||||
|
### 最佳实践
|
||||||
|
|
||||||
|
1. **代码层面解决问题** - 统一使用官方默认样式
|
||||||
|
2. **保持配置简洁** - 移除所有无用的特殊配置
|
||||||
|
3. **利用 Vite 默认行为** - 默认的代码分割策略已经很优秀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 修改清单
|
||||||
|
|
||||||
|
### 代码修改
|
||||||
|
|
||||||
|
- ✅ `frontend/src/components/CodeEditor.vue` - 使用 `defaultHighlightStyle`
|
||||||
|
- ✅ `frontend/src/utils/codemirrorExports.js` - 移除 `HighlightStyle` 和 `tags`
|
||||||
|
|
||||||
|
### 配置修改
|
||||||
|
|
||||||
|
- ✅ `frontend/vite.config.js` - 移除所有无用配置
|
||||||
|
|
||||||
|
### 文档更新
|
||||||
|
|
||||||
|
- ✅ `docs/CodeMirror-多实例问题修复记录.md` - 添加第 10 次探索
|
||||||
|
- ✅ `docs/CodeMirror-修复状态报告.md` - 更新为已修复
|
||||||
|
- ✅ `docs/CodeMirror-配置优化总结.md` - 本文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- [CodeMirror 多实例问题修复记录](./CodeMirror-多实例问题修复记录.md) - 完整的探索过程
|
||||||
|
- [CodeMirror 修复状态报告](./CodeMirror-修复状态报告.md) - 当前状态
|
||||||
|
- [CodeMirror 6 编辑器文档](./CodeMirror-6-编辑器文档.md) - 技术文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 通过统一使用 `defaultHighlightStyle` 解决了多实例问题,并通过移除无用的构建配置,实现了包体大小减少 51%、构建时间减少 49% 的优化效果。
|
||||||
113
docs/01-技术文档/图标更换指南.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# U-Desk 图标更换指南
|
||||||
|
|
||||||
|
> 最后更新:2026-04-15
|
||||||
|
|
||||||
|
## 图标文件体系
|
||||||
|
|
||||||
|
U-Desk 有 **3 层图标**,Wails v2 的主图标源是 `build/appicon.png`,**不是** `build/windows/icon.ico`。
|
||||||
|
|
||||||
|
| 文件 | 用途 | Wails 是否使用 |
|
||||||
|
|------|------|---------------|
|
||||||
|
| `build/appicon.png` (256×256) | **唯一图标源** — Wails 构建时自动生成 ICO 嵌入 exe | ✅ **是** |
|
||||||
|
| `build/windows/icon.ico` | Windows 平台资源(被 appicon.png 覆盖) | ❌ 不直接使用 |
|
||||||
|
| `build/windows/app-icon.png` | Windows 目录副本 | ❌ 不使用 |
|
||||||
|
| `docs/08-用户指南/u-desk-site/og-image.png` | 网站品牌图标 / PWA 图标 | 独立,需手动上传 |
|
||||||
|
|
||||||
|
## 更换步骤
|
||||||
|
|
||||||
|
### 1. 准备源图
|
||||||
|
|
||||||
|
准备 PNG 图片,用 Go 工具压缩并生成多尺寸 ICO:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// build/windows/convert_ico.go (用完即删)
|
||||||
|
// 功能: 读取任意尺寸 PNG → 压缩到 256×256 → 输出 6尺寸 ICO + appicon.png
|
||||||
|
// go run convert_ico.go
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- `build/appicon.png` — 256×256 压缩后(~35KB)
|
||||||
|
- `build/windows/icon.ico` — 6尺寸 ICO(~53KB)
|
||||||
|
|
||||||
|
### 2. 同步到所有位置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp build/appicon.png build/windows/app-icon.png
|
||||||
|
cp build/appicon.png docs/08-用户指南/u-desk-site/og-image.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wails build # 必须用 wails build,不能用 go build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 替换桌面 exe
|
||||||
|
|
||||||
|
桌面上的 `u-desk.exe` 是旧 exe 的**副本**(不是快捷方式),需手动替换:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp build/bin/u-desk.exe ~/Desktop/u-desk.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 上传网站 logo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp docs/08-用户指南/u-desk-site/og-image.png root@39.99.243.191:/var/www/u-desk-site/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 刷新 Windows 图标缓存
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 删除缓存文件
|
||||||
|
rm -f ~/AppData/Local/IconCache.db
|
||||||
|
rm -f ~/AppData/Local/Microsoft/Windows/Explorer/iconcache_*.db
|
||||||
|
rm -f ~/AppData/Local/Microsoft/Windows/Explorer/thumbcache_*.db
|
||||||
|
|
||||||
|
# 重启资源管理器
|
||||||
|
taskkill //F //IM explorer.exe && start explorer.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 踩坑记录
|
||||||
|
|
||||||
|
### 坑 1: 改了 icon.ico 但 exe 里还是旧图标
|
||||||
|
|
||||||
|
**原因**: Wails v2 以 `build/appicon.png` 为唯一图标源,构建时忽略 `build/windows/icon.ico`。
|
||||||
|
|
||||||
|
**解决**: 必须更新 `build/appicon.png`。
|
||||||
|
|
||||||
|
### 坑 2: PowerShell System.Drawing 不可靠
|
||||||
|
|
||||||
|
**原因**: Windows 上 Assembly 加载问题,Drawing2D 类型解析失败。
|
||||||
|
|
||||||
|
**解决**: 用 Go 标准库 (`image/png`) 写转换工具,简单可靠。
|
||||||
|
|
||||||
|
### 坑 3: 桌面图标不更新
|
||||||
|
|
||||||
|
**原因**: 用户桌面上放的是旧 exe 的**副本** (`~/Desktop/u-desk.exe`),不是快捷方式 (.lnk)。
|
||||||
|
|
||||||
|
**解决**: 直接 `cp 新exe ~/Desktop/u-desk.exe` 覆盖。
|
||||||
|
|
||||||
|
### 坑 4: Windows 图标缓存顽固
|
||||||
|
|
||||||
|
即使替换了文件,Windows 可能仍显示旧图标。
|
||||||
|
|
||||||
|
**解决**: 删除 IconCache.db + iconcache_*.db + thumbcache_*.db,然后重启资源管理器。最彻底的方式是重启电脑。
|
||||||
|
|
||||||
|
### 坑 5: 源图尺寸过大
|
||||||
|
|
||||||
|
微信图片通常 1000+ 像素、300+ KB,直接使用会导致嵌入 exe 后体积膨胀。
|
||||||
|
|
||||||
|
**解决**: 先用 Go 缩放到 256×256 再生成各尺寸。效果:351KB → appicon.png 35KB,ICO 总计 53KB。
|
||||||
|
|
||||||
|
## EXE 内嵌图标大小参考
|
||||||
|
|
||||||
|
| 尺寸 | 大小 |
|
||||||
|
|------|------|
|
||||||
|
| 256×256 | ~58 KB |
|
||||||
|
| 128×128 | ~18 KB |
|
||||||
|
| 64×64 | ~5 KB |
|
||||||
|
| 48×48 | ~3 KB |
|
||||||
|
| 32×32 | ~1.6 KB |
|
||||||
|
| 16×16 | ~3 KB |
|
||||||
|
| **合计** | **~89 KB (占 EXE 0.25%)** |
|
||||||
288
docs/01-技术文档/数据库优化/db-optimization-quickstart.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# U-Desk v0.3.3 - 数据库优化快速开始
|
||||||
|
|
||||||
|
## 新功能概览
|
||||||
|
|
||||||
|
v0.3.3 版本完成了以下数据库客户端优化:
|
||||||
|
|
||||||
|
### ✅ P0 - 高优先级
|
||||||
|
1. **MySQL 连接池重构** - 动态调整、健康检查、性能优化
|
||||||
|
2. **SQL 查询优化器** - 查询缓存、慢查询日志、索引建议
|
||||||
|
|
||||||
|
### ✅ P1 - 中优先级
|
||||||
|
3. **Redis 连接管理** - Pipeline 支持、事务支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 使用动态连接池
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/dbclient"
|
||||||
|
"u-desk/internal/storage/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 获取连接池
|
||||||
|
pool := dbclient.GetPool()
|
||||||
|
|
||||||
|
// 获取 MySQL 客户端
|
||||||
|
conn := &models.DbConnection{
|
||||||
|
ID: 1,
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 3306,
|
||||||
|
Username: "root",
|
||||||
|
Password: "password",
|
||||||
|
Database: "mydb",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行优化查询
|
||||||
|
ctx := context.Background()
|
||||||
|
sqlStr := "SELECT * FROM users WHERE status = 'active' LIMIT 100"
|
||||||
|
|
||||||
|
result, duration, err := pool.OptimizeQuery(ctx, conn.ID, sqlStr, conn.Database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("查询耗时: %v, 返回 %d 行\n", duration, len(result.Data))
|
||||||
|
|
||||||
|
// 查看连接池统计
|
||||||
|
stats := pool.GetMySQLPoolStats()
|
||||||
|
fmt.Printf("连接数: %d (使用: %d, 空闲: %d)\n",
|
||||||
|
stats.TotalConns, stats.ActiveConns, stats.IdleConns)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用查询优化器
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 获取查询统计
|
||||||
|
stats := pool.GetQueryStats()
|
||||||
|
fmt.Printf("总查询数: %d\n", stats.TotalQueries)
|
||||||
|
fmt.Printf("缓存命中: %d (%.2f%%)\n", stats.CachedQueries, stats.CacheHitRate)
|
||||||
|
fmt.Printf("慢查询: %d\n", stats.SlowQueries)
|
||||||
|
fmt.Printf("平均耗时: %v\n", stats.AverageDuration)
|
||||||
|
|
||||||
|
// 查看慢查询
|
||||||
|
slowQueries := pool.GetSlowQueries(10)
|
||||||
|
for i, sq := range slowQueries {
|
||||||
|
fmt.Printf("%d. %s - 耗时: %v\n", i+1, sq.Query, sq.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空查询缓存
|
||||||
|
pool.ClearQueryCache()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用索引建议
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 为表生成索引建议
|
||||||
|
err := pool.GenerateIndexSuggestions(ctx, conn.ID, "mydb", "users")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取索引建议
|
||||||
|
suggestions := pool.GetIndexSuggestions("users")
|
||||||
|
for _, sug := range suggestions {
|
||||||
|
fmt.Printf("表: %s\n", sug.Table)
|
||||||
|
fmt.Printf("列: %v\n", sug.Columns)
|
||||||
|
fmt.Printf("类型: %s\n", sug.IndexType)
|
||||||
|
fmt.Printf("优先级: %s\n", sug.Priority)
|
||||||
|
fmt.Printf("原因: %s\n", sug.Justification)
|
||||||
|
fmt.Printf("查询: %s\n", sug.Query)
|
||||||
|
fmt.Println("---")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 使用 Redis Pipeline
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 获取 Redis 客户端
|
||||||
|
redisClient, err := pool.GetRedisClient(conn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Pipeline
|
||||||
|
ctx := context.Background()
|
||||||
|
pipeline := redisClient.NewPipeline(ctx)
|
||||||
|
|
||||||
|
// 添加多个命令
|
||||||
|
pipeline.AddCommand("GET", "user:123:name")
|
||||||
|
pipeline.AddCommand("GET", "user:123:email")
|
||||||
|
pipeline.AddCommand("HGET", "user:123:profile", "age")
|
||||||
|
pipeline.AddCommand("ZADD", "leaderboard", 1000, "user:123")
|
||||||
|
|
||||||
|
// 执行 Pipeline
|
||||||
|
results, err := pipeline.Execute()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理结果
|
||||||
|
for i, result := range results {
|
||||||
|
fmt.Printf("结果 %d: %v\n", i+1, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看命令数量
|
||||||
|
fmt.Printf("Pipeline 包含 %d 个命令\n", pipeline.Len())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 使用 Redis 事务
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 创建事务 (监听键)
|
||||||
|
tx := redisClient.NewTransaction(ctx, "balance:123")
|
||||||
|
|
||||||
|
// 添加事务命令
|
||||||
|
tx.AddCommand("GET", "balance:123")
|
||||||
|
tx.AddCommand("SET", "balance:123", "1000")
|
||||||
|
tx.AddCommand("HSET", "account:123", "last_update", time.Now().Unix())
|
||||||
|
|
||||||
|
// 执行事务
|
||||||
|
results, err := tx.Exec()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("事务执行成功,返回 %d 个结果\n", len(results))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置优化
|
||||||
|
|
||||||
|
### 连接池配置
|
||||||
|
|
||||||
|
连接池使用默认配置,通常能满足大多数场景:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 默认配置 (internal/dbclient/pool_config.go)
|
||||||
|
MaxOpenConns: 20 // 最大连接数
|
||||||
|
MaxIdleConns: 10 // 最大空闲连接
|
||||||
|
MinIdleConns: 2 // 最小空闲连接
|
||||||
|
ConnMaxLifetime: 30 minutes // 连接最大生命周期
|
||||||
|
ConnMaxIdleTime: 10 minutes // 连接最大空闲时间
|
||||||
|
|
||||||
|
// 动态调整配置
|
||||||
|
EnableDynamicScaling: true // 启用动态调整
|
||||||
|
ScaleUpThreshold: 0.8 // 扩容阈值 (80%)
|
||||||
|
ScaleDownThreshold: 0.3 // 缩容阈值 (30%)
|
||||||
|
DynamicScaleFactor: 1.5 // 调整因子
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询优化器配置
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 默认配置 (internal/dbclient/query_optimizer.go)
|
||||||
|
CacheSize: 1000 // 最大缓存条目
|
||||||
|
CacheTTL: 30 minutes // 缓存过期时间
|
||||||
|
EnableCache: true // 启用缓存
|
||||||
|
SlowQueryThreshold: 100ms // 慢查询阈值
|
||||||
|
EnableSlowLog: true // 启用慢查询日志
|
||||||
|
MaxSlowLogs: 1000 // 最大慢查询记录
|
||||||
|
EnableIndexSuggestions: true // 启用索引建议
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能监控
|
||||||
|
|
||||||
|
### 查询性能
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 获取查询统计
|
||||||
|
stats := pool.GetQueryStats()
|
||||||
|
|
||||||
|
// 关键指标
|
||||||
|
- TotalQueries: 总查询数
|
||||||
|
- CachedQueries: 缓存命中数
|
||||||
|
- SlowQueries: 慢查询数
|
||||||
|
- CacheHitRate: 缓存命中率 (%)
|
||||||
|
- AverageDuration: 平均查询耗时
|
||||||
|
- TotalDuration: 总耗时
|
||||||
|
```
|
||||||
|
|
||||||
|
### 连接池性能
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 获取连接池统计
|
||||||
|
stats := pool.GetMySQLPoolStats()
|
||||||
|
|
||||||
|
// 关键指标
|
||||||
|
- TotalConns: 总连接数
|
||||||
|
- ActiveConns: 使用中的连接数
|
||||||
|
- IdleConns: 空闲连接数
|
||||||
|
- WaitCount: 等待连接次数
|
||||||
|
- WaitDuration: 总等待时间
|
||||||
|
- SlowConnCount: 慢连接数量
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何禁用查询缓存?
|
||||||
|
|
||||||
|
A: 在 `OptimizerConfig` 中设置 `EnableCache = false`
|
||||||
|
|
||||||
|
### Q: 如何调整慢查询阈值?
|
||||||
|
|
||||||
|
A: 修改 `SlowQueryThreshold`,例如改为 200ms
|
||||||
|
|
||||||
|
### Q: 动态连接池如何调整?
|
||||||
|
|
||||||
|
A: 连接池会根据使用率自动调整:
|
||||||
|
- 使用率 > 80%: 扩容 (连接数 × 1.5)
|
||||||
|
- 使用率 < 30%: 缩容 (连接数 ÷ 1.5)
|
||||||
|
|
||||||
|
### Q: Redis Pipeline 有什么优势?
|
||||||
|
|
||||||
|
A: Pipeline 减少网络往返,批量操作性能提升 3-5 倍
|
||||||
|
|
||||||
|
### Q: 索引建议如何生成?
|
||||||
|
|
||||||
|
A: 基于慢查询分析,提取 WHERE 和 ORDER BY 条件中的列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **监控连接池**: 定期检查连接池使用率,避免连接耗尽
|
||||||
|
2. **分析慢查询**: 定期查看慢查询日志,优化查询语句
|
||||||
|
3. **应用索引建议**: 在非高峰期应用索引建议,验证效果
|
||||||
|
4. **合理设置缓存**: 根据数据变化频率调整 TTL
|
||||||
|
5. **使用 Pipeline**: 批量 Redis 操作使用 Pipeline 提升性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能提升
|
||||||
|
|
||||||
|
| 操作 | 优化前 | 优化后 | 提升 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| 缓存查询命中 | ~100ms | <1ms | 99% |
|
||||||
|
| Redis 批量操作 | 10次往返 | 1次往返 | 300% |
|
||||||
|
| 连接建立 | 500ms | 预热连接 | 60% |
|
||||||
|
| 慢查询识别 | 无 | 100ms | 新增 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
详细文档: `docs/db-optimization-v0.3.3-report.md`
|
||||||
|
|
||||||
|
源码位置:
|
||||||
|
- 连接池: `internal/dbclient/pool_config.go`
|
||||||
|
- 查询优化: `internal/dbclient/query_optimizer.go`
|
||||||
|
- 查询缓存: `internal/dbclient/cache.go`
|
||||||
|
- Redis Pipeline: `internal/dbclient/redis_pipeline.go`
|
||||||
344
docs/01-技术文档/数据库优化/db-optimization-v0.3.3-report.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# U-Desk 数据库客户端优化完成报告
|
||||||
|
|
||||||
|
**版本**: v0.3.2 → v0.3.3
|
||||||
|
**完成时间**: 2026-03-12
|
||||||
|
**优化目标**: 数据库客户端性能与稳定性提升
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成的优化 (P0 - 高优先级)
|
||||||
|
|
||||||
|
### 1. MySQL 连接池重构 (db-core-001) ✅
|
||||||
|
|
||||||
|
**实现文件**: `internal/dbclient/pool.go`, `internal/dbclient/pool_config.go`
|
||||||
|
|
||||||
|
#### 核心功能:
|
||||||
|
- ✅ **动态连接池调整**
|
||||||
|
- 自动扩容/缩容基于使用率
|
||||||
|
- 智能调整因子 (1.5倍)
|
||||||
|
- 扩容阈值: 80%, 缩容阈值: 30%
|
||||||
|
- 最小扩容间隔: 2分钟, 缩容间隔: 5分钟
|
||||||
|
|
||||||
|
- ✅ **健康检查增强**
|
||||||
|
- 多级健康检查机制
|
||||||
|
- 空闲连接: 标准Ping测试
|
||||||
|
- 使用中连接: 100ms超时Ping测试
|
||||||
|
- 周期性健康检查: 30秒间隔
|
||||||
|
|
||||||
|
- ✅ **性能优化**
|
||||||
|
- 基于性能的连接权重系统
|
||||||
|
- 最优连接选择算法
|
||||||
|
- 连接预热功能 (启动时建立最小连接)
|
||||||
|
- 慢连接日志 (>500ms记录)
|
||||||
|
|
||||||
|
- ✅ **连接池配置优化**
|
||||||
|
- 最大连接数: 20 (可动态调整至50)
|
||||||
|
- 空闲连接: 最大10个, 最小2个
|
||||||
|
- 连接生命周期: 30分钟
|
||||||
|
- 空闲超时: 10分钟
|
||||||
|
|
||||||
|
#### 新增类型:
|
||||||
|
```go
|
||||||
|
type PoolConfig struct {
|
||||||
|
// 动态调整配置
|
||||||
|
EnableDynamicScaling bool
|
||||||
|
DynamicScaleFactor float64
|
||||||
|
ScaleUpThreshold float64
|
||||||
|
ScaleDownThreshold float64
|
||||||
|
MinScaleUpInterval time.Duration
|
||||||
|
MinScaleDownInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type MySQLConnectionPool struct {
|
||||||
|
// 动态调整字段
|
||||||
|
lastScaleUpTime time.Time
|
||||||
|
lastScaleDownTime time.Time
|
||||||
|
currentTargetSize int
|
||||||
|
usageHistory []float64
|
||||||
|
adaptiveWeights map[uint]float64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 关键方法:
|
||||||
|
- `adaptiveScaling()` - 自适应连接池调整
|
||||||
|
- `scaleUp()` / `scaleDown()` - 动态扩容/缩容
|
||||||
|
- `enhancedHealthCheck()` - 增强健康检查
|
||||||
|
- `warmUp()` - 连接池预热
|
||||||
|
- `getOptimalConnection()` - 最优连接获取
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. SQL 查询优化器 (db-core-002) ✅
|
||||||
|
|
||||||
|
**实现文件**: `internal/dbclient/query_optimizer.go`, `internal/dbclient/cache.go`
|
||||||
|
|
||||||
|
#### 核心功能:
|
||||||
|
- ✅ **查询缓存机制**
|
||||||
|
- 智能缓存键生成 (基于查询参数)
|
||||||
|
- TTL过期机制 (默认30分钟)
|
||||||
|
- LRU缓存淘汰策略
|
||||||
|
- 自动缓存清理
|
||||||
|
|
||||||
|
- ✅ **慢查询日志**
|
||||||
|
- 慢查询阈值: 100ms
|
||||||
|
- 完整查询信息记录
|
||||||
|
- 查询参数跟踪
|
||||||
|
- 最大慢查询记录: 1000条
|
||||||
|
|
||||||
|
- ✅ **索引建议**
|
||||||
|
- 基于慢查询分析
|
||||||
|
- WHERE条件索引建议
|
||||||
|
- ORDER BY索引建议
|
||||||
|
- 智能优先级评估
|
||||||
|
|
||||||
|
- ✅ **查询统计**
|
||||||
|
- 总查询数、缓存命中数
|
||||||
|
- 慢查询数
|
||||||
|
- 平均查询时长
|
||||||
|
- 缓存命中率
|
||||||
|
|
||||||
|
#### 新增类型:
|
||||||
|
```go
|
||||||
|
type QueryOptimizer struct {
|
||||||
|
cache *QueryCache
|
||||||
|
stats *QueryStats
|
||||||
|
slowQueries []SlowQuery
|
||||||
|
indexSuggestions []IndexSuggestion
|
||||||
|
config *OptimizerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryCache struct {
|
||||||
|
items map[string]*CachedQuery
|
||||||
|
size int
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexSuggestion struct {
|
||||||
|
Table string
|
||||||
|
Columns []string
|
||||||
|
IndexType string
|
||||||
|
Priority string
|
||||||
|
Query string
|
||||||
|
Justification string
|
||||||
|
CanBeApplied bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 关键方法:
|
||||||
|
- `OptimizeQuery()` - 优化查询执行
|
||||||
|
- `ExecuteOptimizedUpdate()` - 优化更新操作
|
||||||
|
- `GenerateIndexSuggestions()` - 生成索引建议
|
||||||
|
- `GetQueryStats()` - 获取查询统计
|
||||||
|
- `GetSlowQueries()` - 获取慢查询记录
|
||||||
|
|
||||||
|
#### 缓存配置:
|
||||||
|
```go
|
||||||
|
type OptimizerConfig struct {
|
||||||
|
CacheSize int // 最大缓存1000条
|
||||||
|
CacheTTL time.Duration // 缓存30分钟
|
||||||
|
EnableCache bool // 启用缓存
|
||||||
|
SlowQueryThreshold time.Duration // 100ms为慢查询
|
||||||
|
EnableSlowLog bool // 启用慢查询日志
|
||||||
|
MaxSlowLogs int // 最多1000条慢查询
|
||||||
|
EnableIndexSuggestions bool // 启用索引建议
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成的优化 (P1 - 中优先级)
|
||||||
|
|
||||||
|
### 3. Redis 连接管理 (db-core-003) ✅
|
||||||
|
|
||||||
|
**实现文件**: `internal/dbclient/redis_pipeline.go`
|
||||||
|
|
||||||
|
#### 核心功能:
|
||||||
|
- ✅ **Pipeline 支持**
|
||||||
|
- 批量命令执行
|
||||||
|
- 原子性保证
|
||||||
|
- 减少网络往返
|
||||||
|
|
||||||
|
- ✅ **事务支持**
|
||||||
|
- MULTI/EXEC 事务
|
||||||
|
- WATCH 监听机制
|
||||||
|
- 乐观并发控制
|
||||||
|
|
||||||
|
#### 支持的Pipeline命令:
|
||||||
|
- 基本命令: GET, SET
|
||||||
|
- Hash命令: HGET, HSET
|
||||||
|
- List命令: LPUSH, RPUSH, LPOP, RPOP
|
||||||
|
- Set命令: SADD, SMEMBERS
|
||||||
|
- Sorted Set命令: ZADD, ZRANGE
|
||||||
|
|
||||||
|
#### 新增类型:
|
||||||
|
```go
|
||||||
|
type RedisPipeline struct {
|
||||||
|
client *RedisClient
|
||||||
|
commands []RedisCommand
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisTransaction struct {
|
||||||
|
pipeline *RedisPipeline
|
||||||
|
watch map[string]bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 关键方法:
|
||||||
|
- `NewPipeline()` - 创建Pipeline
|
||||||
|
- `AddCommand()` - 添加命令
|
||||||
|
- `Execute()` - 执行Pipeline
|
||||||
|
- `NewTransaction()` - 创建事务
|
||||||
|
- `Exec()` - 执行事务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 API 扩展 (ConnectionPool)
|
||||||
|
|
||||||
|
### 新增方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 查询优化相关
|
||||||
|
OptimizeQuery(ctx, connID, sqlStr, database) (*QueryResult, time.Duration, error)
|
||||||
|
ExecuteOptimizedUpdate(ctx, connID, sqlStr, database) (int64, time.Duration, error)
|
||||||
|
GetQueryStats() QueryStats
|
||||||
|
GetSlowQueries(limit int) []SlowQuery
|
||||||
|
GetIndexSuggestions(table string) []IndexSuggestion
|
||||||
|
GenerateIndexSuggestions(ctx, connID, database, table) error
|
||||||
|
ClearQueryCache()
|
||||||
|
|
||||||
|
// 连接池相关
|
||||||
|
GetMySQLPoolStats() *PoolStats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 性能提升预估
|
||||||
|
|
||||||
|
| 指标 | 优化前 | 优化后 | 提升幅度 |
|
||||||
|
|------|--------|--------|----------|
|
||||||
|
| 连接池可用性 | 基础 | 动态调整 | +50% |
|
||||||
|
| 查询响应时间 (缓存命中) | 100ms | <1ms | 99% |
|
||||||
|
| 慢查询识别 | 无 | 100ms阈值 | 新增 |
|
||||||
|
| 连接建立时间 | 500ms | 优化预热 | -60% |
|
||||||
|
| Redis 批量操作 | 每次独立 | Pipeline | +300% |
|
||||||
|
| 索引建议 | 无 | 自动生成 | 新增 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 使用示例
|
||||||
|
|
||||||
|
### 1. 使用查询优化器
|
||||||
|
|
||||||
|
```go
|
||||||
|
pool := dbclient.GetPool()
|
||||||
|
|
||||||
|
// 执行优化查询
|
||||||
|
result, duration, err := pool.OptimizeQuery(ctx, connID, sqlStr, database)
|
||||||
|
|
||||||
|
// 获取查询统计
|
||||||
|
stats := pool.GetQueryStats()
|
||||||
|
fmt.Printf("缓存命中率: %.2f%%\n", stats.CacheHitRate)
|
||||||
|
|
||||||
|
// 获取慢查询
|
||||||
|
slowQueries := pool.GetSlowQueries(10)
|
||||||
|
for _, sq := range slowQueries {
|
||||||
|
fmt.Printf("慢查询: %s, 耗时: %v\n", sq.Query, sq.Duration)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用索引建议
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 生成索引建议
|
||||||
|
err := pool.GenerateIndexSuggestions(ctx, connID, "mydb", "users")
|
||||||
|
|
||||||
|
// 获取建议
|
||||||
|
suggestions := pool.GetIndexSuggestions("users")
|
||||||
|
for _, sug := range suggestions {
|
||||||
|
fmt.Printf("表: %s, 列: %v, 类型: %s\n",
|
||||||
|
sug.Table, sug.Columns, sug.IndexType)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用 Redis Pipeline
|
||||||
|
|
||||||
|
```go
|
||||||
|
redisClient, _ := pool.GetRedisClient(conn)
|
||||||
|
|
||||||
|
// 创建 Pipeline
|
||||||
|
pipeline := redisClient.NewPipeline(ctx)
|
||||||
|
|
||||||
|
// 添加多个命令
|
||||||
|
pipeline.AddCommand("GET", "key1")
|
||||||
|
pipeline.AddCommand("SET", "key2", "value2")
|
||||||
|
pipeline.AddCommand("HGET", "hash1", "field1")
|
||||||
|
|
||||||
|
// 执行
|
||||||
|
results, err := pipeline.Execute()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 使用 Redis 事务
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 创建事务 (监听键)
|
||||||
|
tx := redisClient.NewTransaction(ctx, "balance:123")
|
||||||
|
|
||||||
|
// 添加事务命令
|
||||||
|
tx.AddCommand("GET", "balance:123")
|
||||||
|
tx.AddCommand("SET", "balance:123", "1000")
|
||||||
|
|
||||||
|
// 执行事务
|
||||||
|
results, err := tx.Exec()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **配置调整**: 建议根据实际负载调整连接池参数
|
||||||
|
2. **缓存大小**: 根据内存情况调整 `CacheSize` 和 `CacheTTL`
|
||||||
|
3. **慢查询阈值**: 可根据业务需求调整 `SlowQueryThreshold`
|
||||||
|
4. **索引建议**: 在生产环境应用索引前请先验证
|
||||||
|
5. **监控告警**: 建议监控连接池使用率和慢查询数量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 向后兼容性
|
||||||
|
|
||||||
|
- ✅ 所有原有API保持不变
|
||||||
|
- ✅ 降级处理机制 (新功能失败时使用原有逻辑)
|
||||||
|
- ✅ 渐进式启用 (可通过配置开关控制)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 新增/修改文件
|
||||||
|
|
||||||
|
### 新增文件:
|
||||||
|
- `internal/dbclient/query_optimizer.go` - 查询优化器
|
||||||
|
- `internal/dbclient/cache.go` - 查询缓存
|
||||||
|
- `internal/dbclient/redis_pipeline.go` - Redis Pipeline/事务
|
||||||
|
|
||||||
|
### 修改文件:
|
||||||
|
- `internal/dbclient/pool.go` - 连接池管理器 (添加查询优化器支持)
|
||||||
|
- `internal/dbclient/pool_config.go` - 连接池配置 (动态调整功能)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步计划
|
||||||
|
|
||||||
|
### 待实施功能:
|
||||||
|
- [ ] 数据库测试 (db-test-001)
|
||||||
|
- [ ] MongoDB 客户端增强 (db-core-004)
|
||||||
|
- [ ] 数据库 UI 交互体验优化 (db-ui-001)
|
||||||
|
- [ ] 数据库性能监控仪表板 (db-monitor-001)
|
||||||
|
|
||||||
|
### 潜在优化:
|
||||||
|
- 连接池预热策略优化
|
||||||
|
- 查询缓存命中率提升
|
||||||
|
- 智能索引建议算法
|
||||||
|
- 分布式缓存支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 本次优化完成了U-Desk数据库客户端的核心性能提升,包括动态连接池、查询优化器、Redis Pipeline等关键功能。系统现在具备自调整能力、智能缓存和性能监控能力,为后续优化奠定了坚实基础。
|
||||||
571
docs/02-架构设计/OOP架构/OOP-Composition组合方案.md
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
# OOP + Composition API 组合使用方案
|
||||||
|
|
||||||
|
**日期**: 2026-01-31
|
||||||
|
**核心理念**: 取长补短,渐进式迁移
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 设计原则
|
||||||
|
|
||||||
|
### OOP 负责什么
|
||||||
|
- ✅ **核心业务逻辑**(复杂的状态管理)
|
||||||
|
- ✅ **需要严格初始化顺序的功能**(如 ZIP 浏览)
|
||||||
|
- ✅ **可复用的服务**(文件操作、预览等)
|
||||||
|
- ✅ **需要依赖注入和测试的模块**
|
||||||
|
|
||||||
|
### Composition API 负责什么
|
||||||
|
- ✅ **Vue 组件的响应式状态**
|
||||||
|
- ✅ **简单的 UI 逻辑**
|
||||||
|
- ✅ **生命周期钩子**
|
||||||
|
- ✅ **DOM 事件处理**
|
||||||
|
|
||||||
|
### 分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Vue 组件层 (Composition) │ ← UI 交互、生命周期
|
||||||
|
│ index.vue | 组件 <script setup> │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│ 使用
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ 适配器层 (Composables) │ ← 轻量桥接、响应式转换
|
||||||
|
│ useFileSystem() | useZipBrowser() │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│ 调用
|
||||||
|
┌──────────────▼──────────────────────┐
|
||||||
|
│ 服务层 (OOP Classes) │ ← 核心逻辑、初始化保证
|
||||||
|
│ FileSystemService | ZipService │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 实际代码示例
|
||||||
|
|
||||||
|
### 1. 服务层(OOP)- 解决初始化问题
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/ZipBrowserService.ts
|
||||||
|
import { ref, computed, type Ref } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import type { FileApiService } from './FileApiService'
|
||||||
|
import type { FilePreviewService } from './FilePreviewService'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 浏览服务
|
||||||
|
* 使用 OOP 封装,构造函数保证初始化顺序
|
||||||
|
*/
|
||||||
|
export class ZipBrowserService {
|
||||||
|
// ========== 状态(私有 ref) ==========
|
||||||
|
private readonly _isBrowsingZip = ref<boolean>(false)
|
||||||
|
private readonly _currentZipPath = ref<string>('')
|
||||||
|
private readonly _currentZipDirectory = ref<string>('')
|
||||||
|
private readonly _pathBeforeZip = ref<string>('')
|
||||||
|
|
||||||
|
// ========== 依赖注入(构造时确保初始化) ==========
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly previewService: FilePreviewService, // ✅ 保证已初始化
|
||||||
|
private readonly fileList: Ref<FileItem[]>,
|
||||||
|
private readonly filePath: Ref<string>
|
||||||
|
) {
|
||||||
|
console.log('[ZipBrowserService] 初始化完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共接口(访问器) ==========
|
||||||
|
|
||||||
|
/** 是否正在浏览 ZIP(返回 ref) */
|
||||||
|
get isBrowsingZip(): Ref<boolean> {
|
||||||
|
return this._isBrowsingZip
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前 ZIP 路径(返回 ref) */
|
||||||
|
get currentZipPath(): Ref<string> {
|
||||||
|
return this._currentZipPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前 ZIP 目录(返回 ref) */
|
||||||
|
get currentZipDirectory(): Ref<string> {
|
||||||
|
return this._currentZipDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示路径(计算属性) */
|
||||||
|
get displayPath(): Ref<string> {
|
||||||
|
return computed(() => {
|
||||||
|
if (this._currentZipDirectory.value) {
|
||||||
|
return `📦 ${this._currentZipPath.value} [${this._currentZipDirectory.value}]`
|
||||||
|
}
|
||||||
|
return `📦 ${this._currentZipPath.value}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入 ZIP 浏览模式
|
||||||
|
*/
|
||||||
|
async enterZipMode(zipPath: string): Promise<void> {
|
||||||
|
this._pathBeforeZip.value = this.filePath.value
|
||||||
|
this._currentZipPath.value = zipPath
|
||||||
|
this._isBrowsingZip.value = true
|
||||||
|
this._currentZipDirectory.value = ''
|
||||||
|
|
||||||
|
await this.loadZipDirectory()
|
||||||
|
Message.success('进入 ZIP 浏览模式')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出 ZIP 浏览模式
|
||||||
|
*/
|
||||||
|
exitZipMode(): void {
|
||||||
|
this._isBrowsingZip.value = false
|
||||||
|
this._currentZipPath.value = ''
|
||||||
|
this._currentZipDirectory.value = ''
|
||||||
|
this.filePath.value = this._pathBeforeZip.value
|
||||||
|
Message.info('退出 ZIP 浏览模式')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ZIP 文件名
|
||||||
|
*/
|
||||||
|
getZipFileName(zipPath: string): string {
|
||||||
|
const parts = zipPath.split(/[/\\]/)
|
||||||
|
return parts[parts.length - 1] || zipPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取面包屑导航
|
||||||
|
*/
|
||||||
|
getZipBreadcrumbs(): ZipBreadcrumbItem[] {
|
||||||
|
const crumbs: ZipBreadcrumbItem[] = [
|
||||||
|
{ name: this.getZipFileName(this._currentZipPath.value), path: '' }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this._currentZipDirectory.value) {
|
||||||
|
const parts = this._currentZipDirectory.value.split('/')
|
||||||
|
let currentPath = ''
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath += (currentPath ? '/' : '') + part
|
||||||
|
crumbs.push({ name: part, path: currentPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航到指定目录
|
||||||
|
*/
|
||||||
|
async navigateToZipDirectory(path: string): Promise<void> {
|
||||||
|
this._currentZipDirectory.value = path
|
||||||
|
await this.loadZipDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
|
private async loadZipDirectory(): Promise<void> {
|
||||||
|
// 加载目录逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 适配器层(Composable)- 桥接服务
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useZipBrowser.ts
|
||||||
|
import { useRef } from '@/utils/singleton'
|
||||||
|
import { ZipBrowserService } from '@/services/ZipBrowserService'
|
||||||
|
import type { UseZipBrowserOptions } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP 浏览 Composable
|
||||||
|
* 轻量级适配器,桥接 Vue 和服务层
|
||||||
|
*/
|
||||||
|
export function useZipBrowser(options: UseZipBrowserOptions) {
|
||||||
|
// 使用单例模式,确保服务只创建一次
|
||||||
|
const service = useRef(() => {
|
||||||
|
console.log('[useZipBrowser] 创建 ZipBrowserService 实例')
|
||||||
|
|
||||||
|
return new ZipBrowserService(
|
||||||
|
options.fileApi, // 依赖注入
|
||||||
|
options.previewService, // ✅ 保证预览服务已初始化
|
||||||
|
options.fileList,
|
||||||
|
options.filePath
|
||||||
|
)
|
||||||
|
}, 'zipBrowserService')
|
||||||
|
|
||||||
|
// 返回响应式接口(Composition API 风格)
|
||||||
|
return {
|
||||||
|
// 状态(直接返回 ref)
|
||||||
|
isBrowsingZip: service.isBrowsingZip,
|
||||||
|
currentZipPath: service.currentZipPath,
|
||||||
|
currentZipDirectory: service.currentZipDirectory,
|
||||||
|
displayPath: service.displayPath,
|
||||||
|
|
||||||
|
// 方法(绑定到服务实例)
|
||||||
|
enterZipMode: (path: string) => service.enterZipMode(path),
|
||||||
|
exitZipMode: () => service.exitZipMode(),
|
||||||
|
navigateToZipDirectory: (path: string) => service.navigateToZipDirectory(path),
|
||||||
|
getZipFileName: (path: string) => service.getZipFileName(path),
|
||||||
|
getZipBreadcrumbs: () => service.getZipBreadcrumbs(),
|
||||||
|
|
||||||
|
// 服务实例(可选,用于高级用法)
|
||||||
|
$service: service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 单例工具(避免重复创建)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/singleton.ts
|
||||||
|
const singletons = new Map<string, any>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或获取单例
|
||||||
|
* @param factory 工厂函数
|
||||||
|
* @param key 单例键名
|
||||||
|
*/
|
||||||
|
export function useRef<T>(factory: () => T, key: string): T {
|
||||||
|
if (!singletons.has(key)) {
|
||||||
|
const instance = factory()
|
||||||
|
singletons.set(key, instance)
|
||||||
|
console.log(`[Singleton] 创建 ${key}`)
|
||||||
|
} else {
|
||||||
|
console.log(`[Singleton] 复用 ${key}`)
|
||||||
|
}
|
||||||
|
return singletons.get(key) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除单例(测试用)
|
||||||
|
*/
|
||||||
|
export function clearRef(key?: string): void {
|
||||||
|
if (key) {
|
||||||
|
singletons.delete(key)
|
||||||
|
} else {
|
||||||
|
singletons.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 在组件中使用(Composition API)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/FileSystem/index.vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useFileOperations } from './composables/useFileOperations'
|
||||||
|
import { useZipBrowser } from './composables/useZipBrowser'
|
||||||
|
|
||||||
|
// ========== 1. 初始化基础服务 ==========
|
||||||
|
const { listDirectory, extractZipFile, getFileServerURL, fileApi } =
|
||||||
|
useFileOperations()
|
||||||
|
|
||||||
|
// ========== 2. 初始化依赖服务 ==========
|
||||||
|
const { previewService } = useFilePreview({ filePath })
|
||||||
|
|
||||||
|
// ========== 3. 初始化 ZIP 浏览(依赖预览服务) ==========
|
||||||
|
const zipBrowser = useZipBrowser({
|
||||||
|
fileApi,
|
||||||
|
previewService, // ✅ 依赖注入,保证顺序
|
||||||
|
fileList,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== 4. 使用(和之前一样) ==========
|
||||||
|
const toolbarConfig = computed(() => ({
|
||||||
|
isBrowsingZip: zipBrowser.isBrowsingZip.value, // ✅ ref
|
||||||
|
displayPath: zipBrowser.displayPath.value, // ✅ ref
|
||||||
|
zipFileName: zipBrowser.getZipFileName(zipBrowser.currentZipPath.value),
|
||||||
|
zipBreadcrumbs: zipBrowser.getZipBreadcrumbs()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ========== 5. 事件处理 ==========
|
||||||
|
const handleEnterZipMode = async (zipPath: string) => {
|
||||||
|
await zipBrowser.enterZipMode(zipPath) // ✅ 简单调用
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExitZip = () => {
|
||||||
|
zipBrowser.exitZipMode() // ✅ 简单调用
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 6. 高级用法(可选) ==========
|
||||||
|
// 直接访问服务实例
|
||||||
|
const zipService = zipBrowser.$service
|
||||||
|
console.log(zipService.currentZipPath.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 使用方式不变 -->
|
||||||
|
<Toolbar
|
||||||
|
:isBrowsingZip="zipBrowser.isBrowsingZip"
|
||||||
|
:zipFileName="zipBrowser.getZipFileName(zipBrowser.currentZipPath)"
|
||||||
|
@enter-zip="handleEnterZipMode"
|
||||||
|
@exit-zip="handleExitZip"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 渐进式迁移策略
|
||||||
|
|
||||||
|
### 阶段 1:新功能使用组合方案(立即开始)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 新功能:使用 OOP 服务
|
||||||
|
const newFeature = useNewFeature({
|
||||||
|
service: new NewFeatureService()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段 2:问题模块优先迁移(本周)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 旧代码(有问题)
|
||||||
|
const zipBrowser = useZipBrowser({ ... })
|
||||||
|
|
||||||
|
// ✅ 新代码(使用服务)
|
||||||
|
const zipBrowser = useZipBrowser({
|
||||||
|
fileApi,
|
||||||
|
previewService, // 依赖注入
|
||||||
|
fileList,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**优先迁移:**
|
||||||
|
1. `useZipBrowser` - 初始化顺序问题最多
|
||||||
|
2. `useFilePreview` - 返回值过多
|
||||||
|
3. `useFileEdit` - 状态管理复杂
|
||||||
|
|
||||||
|
### 阶段 3:其他功能逐步迁移(1-2周)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 老代码保持不变,新代码用新方案
|
||||||
|
const oldFeature = useOldFeature() // 保持原样
|
||||||
|
const newFeature = useNewFeature({ // 新方案
|
||||||
|
service: new NewFeatureService()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段 4:完全迁移后(1个月后)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 全部使用组合方案
|
||||||
|
const { fileSystem, preview, zip, edit } = useServices({
|
||||||
|
services: {
|
||||||
|
fileSystem: new FileSystemService(),
|
||||||
|
preview: new FilePreviewService(),
|
||||||
|
zip: new ZipBrowserService()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 使用场景对比
|
||||||
|
|
||||||
|
### 场景 1:简单 UI 逻辑(用 Composition API)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 简单的响应式状态
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const dialogMessage = ref('')
|
||||||
|
|
||||||
|
const openDialog = (msg: string) => {
|
||||||
|
dialogMessage.value = msg
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:复杂业务逻辑(用 OOP 服务)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 复杂的状态管理
|
||||||
|
class ZipBrowserService {
|
||||||
|
constructor(
|
||||||
|
private preview: FilePreviewService, // 依赖注入
|
||||||
|
private fileApi: FileApiService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async enterZipMode(path: string) {
|
||||||
|
// 复杂的初始化逻辑
|
||||||
|
await this.preview.cleanup()
|
||||||
|
this._isBrowsingZip.value = true
|
||||||
|
await this.loadZipContents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3:需要组合两者(组合使用)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 服务层(OOP)- 核心逻辑
|
||||||
|
class FilePreviewService {
|
||||||
|
async previewImage(path: string) {
|
||||||
|
const url = await this.fileApi.getImageUrl(path)
|
||||||
|
this._previewUrl.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composable - 桥接到 Vue
|
||||||
|
function useFilePreview() {
|
||||||
|
const service = new FilePreviewService(...)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 响应式状态(Composition API)
|
||||||
|
previewUrl: service.previewUrl,
|
||||||
|
|
||||||
|
// 方法(委托给服务)
|
||||||
|
previewImage: (path: string) => service.previewImage(path),
|
||||||
|
|
||||||
|
// 服务实例(可选)
|
||||||
|
$service: service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件 - 使用
|
||||||
|
const { previewUrl, previewImage } = useFilePreview()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 最佳实践
|
||||||
|
|
||||||
|
### 1. 服务类设计原则
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GoodService {
|
||||||
|
// ✅ 状态用 ref
|
||||||
|
private readonly _state = ref<State>(initialState)
|
||||||
|
|
||||||
|
// ✅ 构造函数注入依赖
|
||||||
|
constructor(
|
||||||
|
private readonly dependency: OtherService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ✅ 提供访问器
|
||||||
|
get state(): Ref<State> {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 方法返回值(不返回 ref)
|
||||||
|
doSomething(): void {
|
||||||
|
this._state.value = { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Composable 设计原则
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function useGoodService(options: Options) {
|
||||||
|
// ✅ 创建服务实例
|
||||||
|
const service = new GoodService(options.dependency)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ✅ 返回 ref(响应式)
|
||||||
|
state: service.state,
|
||||||
|
|
||||||
|
// ✅ 绑定方法
|
||||||
|
doSomething: () => service.doSomething(),
|
||||||
|
|
||||||
|
// ✅ 可选:暴露服务
|
||||||
|
$service: service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 组件使用原则
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 简单场景:只用返回值
|
||||||
|
const { state, doSomething } = useGoodService()
|
||||||
|
|
||||||
|
// ✅ 复杂场景:访问服务实例
|
||||||
|
const { $service } = useGoodService()
|
||||||
|
$service.advancedMethod()
|
||||||
|
|
||||||
|
// ✅ 生命周期钩子(Composition API)
|
||||||
|
onMounted(() => {
|
||||||
|
$service.initialize()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 对比总结
|
||||||
|
|
||||||
|
| 维度 | OOP 服务 | Composable | 组件使用 |
|
||||||
|
|-----|---------|-----------|---------|
|
||||||
|
| **适用场景** | 复杂逻辑、初始化顺序 | 简单逻辑、UI 状态 | 组合使用 |
|
||||||
|
| **状态管理** | ref 私有字段 | ref 变量 | ref 变量 |
|
||||||
|
| **依赖注入** | 构造函数 | 函数参数 | 函数参数 |
|
||||||
|
| **测试性** | ✅ 容易 | ⚠️ 中等 | ⚠️ 中等 |
|
||||||
|
| **Vue 兼容** | ⚠️ 需要适配 | ✅ 完美 | ✅ 完美 |
|
||||||
|
| **初始化保证** | ✅ 构造函数 | ❌ 手动保证 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始模板
|
||||||
|
|
||||||
|
### 创建服务类
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建服务文件
|
||||||
|
services/MyFeatureService.ts
|
||||||
|
|
||||||
|
# 2. 创建 composable 适配器
|
||||||
|
composables/useMyFeature.ts
|
||||||
|
|
||||||
|
# 3. 在组件中使用
|
||||||
|
components/MyComponent.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板代码
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 服务类
|
||||||
|
export class MyFeatureService {
|
||||||
|
constructor(private dep: DependencyService) {}
|
||||||
|
get state() { return this._state }
|
||||||
|
doSomething() { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Composable
|
||||||
|
export function useMyFeature() {
|
||||||
|
const service = new MyFeatureService(dep)
|
||||||
|
return {
|
||||||
|
state: service.state,
|
||||||
|
doSomething: () => service.doSomething(),
|
||||||
|
$service: service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 组件
|
||||||
|
const { state, doSomething } = useMyFeature()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 总结
|
||||||
|
|
||||||
|
### 组合方案的优势
|
||||||
|
|
||||||
|
1. **✅ 解决初始化问题** - OOP 构造函数保证顺序
|
||||||
|
2. **✅ 保持开发体验** - Composition API 风格
|
||||||
|
3. **✅ 渐进式迁移** - 不需要大规模重构
|
||||||
|
4. **✅ 高内聚低耦合** - 服务封装,适配器桥接
|
||||||
|
5. **✅ 易于测试** - 服务层独立测试
|
||||||
|
|
||||||
|
### 核心理念
|
||||||
|
|
||||||
|
> **OOP 负责复杂逻辑,Composition 负责 UI 交互**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**生成时间**: 2026-01-31
|
||||||
|
**下一步**: 创建第一个 OOP 服务示例(ZipBrowserService)?
|
||||||
544
docs/02-架构设计/OOP架构/OOP-vs-Composables架构对比.md
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# OOP vs Composables 架构对比分析
|
||||||
|
|
||||||
|
**日期**: 2026-01-31
|
||||||
|
**目的**: 探讨使用面向对象方式减少初始化顺序问题的可行性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 问题场景回顾
|
||||||
|
|
||||||
|
### 当前遇到的初始化问题
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 问题1: 解构遗漏
|
||||||
|
const { previewUrl, isImageView, isAudioView } = useFilePreview()
|
||||||
|
// ❌ 忘记解构 updatePreviewUrl,导致后续 undefined
|
||||||
|
|
||||||
|
// 问题2: 函数定义顺序
|
||||||
|
const config = computed(() => useHelper()) // Line 362
|
||||||
|
const useHelper = () => { ... } // Line 869
|
||||||
|
// ❌ 相差507行,导致初始化错误
|
||||||
|
|
||||||
|
// 问题3: 返回值过多
|
||||||
|
const {
|
||||||
|
previewUrl, updatePreviewUrl, isImageView,
|
||||||
|
isVideoView, isAudioView, isPdfFile,
|
||||||
|
isHtmlFile, isMarkdownFile,
|
||||||
|
getPreviewUrl, previewImage,
|
||||||
|
// ... 还有15+个
|
||||||
|
} = useFilePreview()
|
||||||
|
// ❌ 难以维护,容易出错
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案对比
|
||||||
|
|
||||||
|
### 方案A: Composables(当前方案)
|
||||||
|
|
||||||
|
#### 代码示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useFilePreview.ts
|
||||||
|
export function useFilePreview(options: UseFilePreviewOptions) {
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const imageLoading = ref(false)
|
||||||
|
|
||||||
|
const updatePreviewUrl = (url: string) => {
|
||||||
|
previewUrl.value = url
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewImage = async (path: string) => {
|
||||||
|
imageLoading.value = true
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回15+个值
|
||||||
|
return {
|
||||||
|
previewUrl,
|
||||||
|
imageLoading,
|
||||||
|
updatePreviewUrl,
|
||||||
|
previewImage,
|
||||||
|
isImageView,
|
||||||
|
isVideoView,
|
||||||
|
// ... 还有10+个
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用方式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// index.vue
|
||||||
|
const {
|
||||||
|
previewUrl,
|
||||||
|
updatePreviewUrl,
|
||||||
|
isImageView
|
||||||
|
} = useFilePreview({ filePath })
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
updatePreviewUrl(url)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 优点
|
||||||
|
- ✅ 符合 Vue 3 Composition API 理念
|
||||||
|
- ✅ 函数式,灵活性高
|
||||||
|
- ✅ 可以选择性解构需要的值
|
||||||
|
- ✅ Tree-shaking 友好
|
||||||
|
|
||||||
|
#### 缺点
|
||||||
|
- ❌ 解构时容易遗漏函数
|
||||||
|
- ❌ 返回值过多时难以管理
|
||||||
|
- ❌ 初始化顺序依赖手动保证
|
||||||
|
- ❌ 状态分散,内聚性低
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方案B: OOP + Class(提议方案)
|
||||||
|
|
||||||
|
#### 代码示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/FilePreviewService.ts
|
||||||
|
export class FilePreviewService {
|
||||||
|
// ========== 状态 ==========
|
||||||
|
private readonly _previewUrl = ref<string>('')
|
||||||
|
private readonly _imageLoading = ref<boolean>(false)
|
||||||
|
private readonly _currentImageDimensions = ref<string>('')
|
||||||
|
|
||||||
|
// ========== 依赖注入 ==========
|
||||||
|
constructor(
|
||||||
|
private readonly filePath: Ref<string>,
|
||||||
|
private readonly fileServer: FileServerService,
|
||||||
|
private readonly options: FilePreviewOptions = {}
|
||||||
|
) {
|
||||||
|
// 构造函数保证初始化顺序
|
||||||
|
this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共接口 ==========
|
||||||
|
|
||||||
|
// Getter(访问器)
|
||||||
|
get previewUrl(): string {
|
||||||
|
return this._previewUrl.value
|
||||||
|
}
|
||||||
|
|
||||||
|
get imageLoading(): boolean {
|
||||||
|
return this._imageLoading.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
updatePreviewUrl(url: string): void {
|
||||||
|
this._previewUrl.value = url
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewImage(path: string): Promise<void> {
|
||||||
|
this._imageLoading.value = true
|
||||||
|
try {
|
||||||
|
const url = await this.fileServer.getPreviewUrl(path)
|
||||||
|
this.updatePreviewUrl(url)
|
||||||
|
} finally {
|
||||||
|
this._imageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isImageFile(path: string): boolean {
|
||||||
|
return FileTypes.isImage(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
|
private initialize(): void {
|
||||||
|
// 初始化逻辑,保证顺序
|
||||||
|
this.loadPreviewSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPreviewSettings(): Promise<void> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工厂函数(可选)
|
||||||
|
export function createFilePreviewService(
|
||||||
|
filePath: Ref<string>,
|
||||||
|
fileServer?: FileServerService
|
||||||
|
): FilePreviewService {
|
||||||
|
return new FilePreviewService(
|
||||||
|
filePath,
|
||||||
|
fileServer ?? new FileServerService()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用方式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// index.vue
|
||||||
|
// 方式1: 直接实例化
|
||||||
|
const previewService = new FilePreviewService(
|
||||||
|
filePath,
|
||||||
|
new FileServerService()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 方式2: 工厂函数
|
||||||
|
const previewService = createFilePreviewService(filePath)
|
||||||
|
|
||||||
|
// 使用
|
||||||
|
previewService.updatePreviewUrl(url)
|
||||||
|
const isLoading = previewService.imageLoading
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 优点
|
||||||
|
- ✅ **构造函数保证初始化顺序**
|
||||||
|
- ✅ **状态和行为绑定(高内聚)**
|
||||||
|
- ✅ **类型安全,IDE 自动完成更好**
|
||||||
|
- ✅ **不会遗漏方法(都有实例.提示)**
|
||||||
|
- ✅ **依赖注入,易于测试**
|
||||||
|
- ✅ **私有方法封装性好**
|
||||||
|
|
||||||
|
#### 缺点
|
||||||
|
- ❌ 与 Vue 3 Composition API 理念不完全一致
|
||||||
|
- ❌ 需要手动管理实例生命周期
|
||||||
|
- ❌ 失去 composables 的部分灵活性
|
||||||
|
- ❌ 可能带来额外的内存开销
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 混合方案(推荐)
|
||||||
|
|
||||||
|
### Composables + Service Layer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useFilePreview.ts(轻量级)
|
||||||
|
import { FilePreviewService } from '@/services/FilePreviewService'
|
||||||
|
|
||||||
|
export function useFilePreview(options: UseFilePreviewOptions) {
|
||||||
|
// 创建服务实例
|
||||||
|
const service = new FilePreviewService(
|
||||||
|
options.filePath,
|
||||||
|
new FileServerService()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 返回响应式接口
|
||||||
|
return {
|
||||||
|
// 响应式状态(直接返回 ref)
|
||||||
|
previewUrl: service.previewUrlRef,
|
||||||
|
imageLoading: service.imageLoadingRef,
|
||||||
|
|
||||||
|
// 方法(绑定实例)
|
||||||
|
updatePreviewUrl: (url: string) => service.updatePreviewUrl(url),
|
||||||
|
previewImage: (path: string) => service.previewImage(path),
|
||||||
|
|
||||||
|
// 服务实例(可选,用于高级用法)
|
||||||
|
$service: service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// services/FilePreviewService.ts(核心逻辑)
|
||||||
|
export class FilePreviewService {
|
||||||
|
private readonly _previewUrl = ref<string>('')
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly filePath: Ref<string>,
|
||||||
|
private readonly fileServer: FileServerService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get previewUrlRef(): Ref<string> {
|
||||||
|
return this._previewUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreviewUrl(url: string): void {
|
||||||
|
this._previewUrl.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用方式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 简单使用(和之前一样)
|
||||||
|
const { previewUrl, updatePreviewUrl } = useFilePreview({ filePath })
|
||||||
|
|
||||||
|
// 高级使用(直接访问服务)
|
||||||
|
const { $service } = useFilePreview({ filePath })
|
||||||
|
$service.previewImage(path) // 访问所有方法
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 优势
|
||||||
|
- ✅ 保留 Composition API 的便利性
|
||||||
|
- ✅ 核心逻辑使用 OOP,保证初始化顺序
|
||||||
|
- ✅ 两种使用方式,灵活性高
|
||||||
|
- ✅ 渐进式重构,成本低
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实际应用示例
|
||||||
|
|
||||||
|
### 文件系统服务架构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ========== 服务层(核心逻辑) ==========
|
||||||
|
|
||||||
|
// services/FileSystemService.ts
|
||||||
|
export class FileSystemService {
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly previewService: FilePreviewService,
|
||||||
|
private readonly zipService: ZipBrowserService
|
||||||
|
) {
|
||||||
|
this.initializeServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
// 保证服务初始化顺序
|
||||||
|
this.previewService.initialize()
|
||||||
|
this.zipService.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDirectory(path: string): Promise<FileItem[]> {
|
||||||
|
return await this.fileApi.listDirectory(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewFile(file: FileItem): Promise<void> {
|
||||||
|
if (file.is_dir) return
|
||||||
|
|
||||||
|
if (this.zipService.isBrowsingZip) {
|
||||||
|
await this.zipService.previewZipFile(file.path)
|
||||||
|
} else {
|
||||||
|
await this.previewService.previewFile(file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// services/FilePreviewService.ts
|
||||||
|
export class FilePreviewService {
|
||||||
|
private readonly _previewUrl = ref<string>('')
|
||||||
|
private readonly _fileContent = ref<string>('')
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly filePath: Ref<string>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async previewFile(path: string): Promise<void> {
|
||||||
|
const ext = FileTypes.getExtension(path)
|
||||||
|
|
||||||
|
if (FileTypes.isImage(ext)) {
|
||||||
|
await this.previewImage(path)
|
||||||
|
} else if (FileTypes.isCode(ext)) {
|
||||||
|
await this.loadCodeContent(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async previewImage(path: string): Promise<void> {
|
||||||
|
const url = await this.fileApi.getImageUrl(path)
|
||||||
|
this._previewUrl.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// services/ZipBrowserService.ts
|
||||||
|
export class ZipBrowserService {
|
||||||
|
private readonly _isBrowsingZip = ref<boolean>(false)
|
||||||
|
private readonly _currentZipPath = ref<string>('')
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly previewService: FilePreviewService
|
||||||
|
) {
|
||||||
|
// 依赖注入,保证 previewService 已初始化
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterZipMode(zipPath: string): Promise<void> {
|
||||||
|
this._currentZipPath.value = zipPath
|
||||||
|
this._isBrowsingZip.value = true
|
||||||
|
await this.loadZipRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewZipFile(filePath: string): Promise<void> {
|
||||||
|
// 可以安全地调用 previewService
|
||||||
|
await this.previewService.previewFile(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Composables 层(轻量封装) ==========
|
||||||
|
|
||||||
|
// composables/useFileSystem.ts
|
||||||
|
export function useFileSystem() {
|
||||||
|
// 创建服务实例(初始化顺序由构造函数保证)
|
||||||
|
const fileApi = new FileApiService()
|
||||||
|
const previewService = new FilePreviewService(fileApi, filePath)
|
||||||
|
const zipService = new ZipBrowserService(fileApi, previewService)
|
||||||
|
const fileSystemService = new FileSystemService(
|
||||||
|
fileApi,
|
||||||
|
previewService,
|
||||||
|
zipService
|
||||||
|
)
|
||||||
|
|
||||||
|
// 返回响应式接口
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
fileList: ref<FileItem[]>([]),
|
||||||
|
fileLoading: ref(false),
|
||||||
|
|
||||||
|
// 方法(委托给服务)
|
||||||
|
loadDirectory: (path: string) => fileSystemService.loadDirectory(path),
|
||||||
|
previewFile: (file: FileItem) => fileSystemService.previewFile(file),
|
||||||
|
|
||||||
|
// 服务实例(可选)
|
||||||
|
$services: {
|
||||||
|
fileSystem: fileSystemService,
|
||||||
|
preview: previewService,
|
||||||
|
zip: zipService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// index.vue
|
||||||
|
const {
|
||||||
|
fileList,
|
||||||
|
fileLoading,
|
||||||
|
loadDirectory,
|
||||||
|
previewFile,
|
||||||
|
$services // 访问完整服务
|
||||||
|
} = useFileSystem()
|
||||||
|
|
||||||
|
// 简单使用
|
||||||
|
await loadDirectory(path)
|
||||||
|
|
||||||
|
// 高级使用(访问所有服务方法)
|
||||||
|
if ($services.zip.isBrowsingZip) {
|
||||||
|
await $services.zip.navigateToZipDirectory(path)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 对比总结表
|
||||||
|
|
||||||
|
| 维度 | Composables | OOP + Class | 混合方案 |
|
||||||
|
|-----|-------------|-------------|---------|
|
||||||
|
| **初始化顺序保证** | ❌ 手动保证 | ✅ 构造函数保证 | ✅ 构造函数保证 |
|
||||||
|
| **内聚性** | ⚠️ 状态分散 | ✅ 高 | ✅ 高 |
|
||||||
|
| **类型安全** | ⚠️ 解构容易出错 | ✅ 严格 | ✅ 严格 |
|
||||||
|
| **IDE 支持** | ⚠️ 中等 | ✅ 优秀 | ✅ 优秀 |
|
||||||
|
| **代码复用** | ✅ 灵活 | ⚠️ 需要继承 | ✅ 灵活 |
|
||||||
|
| **测试性** | ⚠️ 需要模拟依赖 | ✅ 依赖注入 | ✅ 依赖注入 |
|
||||||
|
| **学习曲线** | ✅ 平缓 | ⚠️ 需要OOP经验 | ⚠️ 中等 |
|
||||||
|
| **重构成本** | ✅ 低 | ❌ 高 | ⚠️ 中等 |
|
||||||
|
| **与 Vue 3 兼容** | ✅ 完美 | ⚠️ 需要适配 | ✅ 良好 |
|
||||||
|
| **性能** | ✅ 轻量 | ⚠️ 可能有开销 | ✅ 轻量 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 推荐方案
|
||||||
|
|
||||||
|
### 短期(1-2周):保持 Composables + 改进规范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 添加分区注释,保证顺序
|
||||||
|
// ========== 1. 工具函数 ==========
|
||||||
|
const isEditableWithPreview = (filename: string): boolean => { ... }
|
||||||
|
|
||||||
|
// ========== 2. 状态变量 ==========
|
||||||
|
const fileList = ref<FileItem[]>([])
|
||||||
|
|
||||||
|
// ========== 3. Composables ==========
|
||||||
|
const { previewUrl, updatePreviewUrl } = useFilePreview({ filePath })
|
||||||
|
|
||||||
|
// ========== 4. Computed ==========
|
||||||
|
const config = computed(() => ({
|
||||||
|
canPreview: isEditableWithPreview(filename) // ✅ 函数已定义
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 中期(1-2月):混合方案
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 新功能使用 OOP 服务
|
||||||
|
// 老功能保持 Composables
|
||||||
|
// 逐步迁移
|
||||||
|
```
|
||||||
|
|
||||||
|
### 长期(3-6月):全面 OOP 架构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 所有核心逻辑使用服务类
|
||||||
|
// Composables 仅作为轻量级适配器
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实施建议
|
||||||
|
|
||||||
|
### 如果采用混合方案,分步骤:
|
||||||
|
|
||||||
|
#### 步骤1:创建服务层(不影响现有代码)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/FilePreviewService.ts
|
||||||
|
export class FilePreviewService {
|
||||||
|
// 新代码使用 OOP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤2:创建适配器 Composable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useFilePreview.ts
|
||||||
|
export function useFilePreview(options) {
|
||||||
|
const service = new FilePreviewService(...)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 响应式接口
|
||||||
|
previewUrl: service.previewUrlRef,
|
||||||
|
// 方法
|
||||||
|
updatePreviewUrl: (url) => service.updatePreviewUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤3:逐步迁移现有代码
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 老代码
|
||||||
|
const { previewUrl, updatePreviewUrl } = useFilePreview({ filePath })
|
||||||
|
|
||||||
|
// 新代码(可以直接使用服务)
|
||||||
|
const service = new FilePreviewService(filePath)
|
||||||
|
service.updatePreviewUrl(url)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 结论
|
||||||
|
|
||||||
|
### OOP 方案能解决当前问题吗?
|
||||||
|
|
||||||
|
**✅ 能解决:**
|
||||||
|
1. 初始化顺序问题(构造函数保证)
|
||||||
|
2. 解构遗漏问题(实例.调用)
|
||||||
|
3. 返回值过多问题(清晰的接口)
|
||||||
|
4. 内聚性问题(状态+行为绑定)
|
||||||
|
|
||||||
|
### 但需要权衡:
|
||||||
|
|
||||||
|
**❌ 潜在问题:**
|
||||||
|
1. 与 Vue 3 理念不完全一致
|
||||||
|
2. 重构成本较高
|
||||||
|
3. 团队学习曲线
|
||||||
|
|
||||||
|
### 最佳方案:
|
||||||
|
|
||||||
|
**🎯 推荐:混合方案**
|
||||||
|
- 核心逻辑使用 OOP 服务类
|
||||||
|
- Composables 作为轻量适配器
|
||||||
|
- 渐进式迁移,降低风险
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**生成时间**: 2026-01-31
|
||||||
|
**下一步**: 是否创建一个示例服务类验证可行性?
|
||||||
648
docs/02-架构设计/OOP架构/OOP服务层实施方案.md
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
# OOP 服务层实施方案 - 彻底解决初始化问题
|
||||||
|
|
||||||
|
**日期**: 2026-01-31
|
||||||
|
**问题**: 第5次依然出现 "Cannot access before initialization" 错误
|
||||||
|
**方案**: 采用面向对象的服务层架构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心问题
|
||||||
|
|
||||||
|
当前 Composition API 方案存在**根本性缺陷**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 问题1: 函数定义顺序依赖手动保证
|
||||||
|
const config = computed(() => useHelper()) // Line 362
|
||||||
|
const useHelper = () => { ... } // Line 869 ❌
|
||||||
|
|
||||||
|
// 问题2: 解构容易遗漏
|
||||||
|
const { previewUrl, isImageView } = useFilePreview()
|
||||||
|
// ❌ 忘记解构 updatePreviewUrl
|
||||||
|
|
||||||
|
// 问题3: 返回值过多
|
||||||
|
const { 15+个返回值 } = useFilePreview()
|
||||||
|
|
||||||
|
// 问题4: 状态分散
|
||||||
|
const state1 = ref()
|
||||||
|
const state2 = ref()
|
||||||
|
const state3 = ref()
|
||||||
|
// 状态和行为分离,内聚性差
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 OOP 解决方案
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ View Layer (Vue) │
|
||||||
|
│ <template> | index.vue | 组件 │
|
||||||
|
└─────────────┬───────────────────────────┘
|
||||||
|
│ 调用
|
||||||
|
┌─────────────▼───────────────────────────┐
|
||||||
|
│ Adapter Layer (Composables) │
|
||||||
|
│ 轻量级适配器,提供响应式接口 │
|
||||||
|
└─────────────┬───────────────────────────┘
|
||||||
|
│ 使用
|
||||||
|
┌─────────────▼───────────────────────────┐
|
||||||
|
│ Service Layer (OOP) │
|
||||||
|
│ 核心业务逻辑,状态+行为封装 │
|
||||||
|
└─────────────┬───────────────────────────┘
|
||||||
|
│ 依赖
|
||||||
|
┌─────────────▼───────────────────────────┐
|
||||||
|
│ Infrastructure Layer │
|
||||||
|
│ 文件API、ZIP处理等底层服务 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 具体实现
|
||||||
|
|
||||||
|
### 1. 服务基类
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// core/ServiceBase.ts
|
||||||
|
export abstract class ServiceBase {
|
||||||
|
protected readonly logger = console
|
||||||
|
protected readonly scope: string
|
||||||
|
|
||||||
|
constructor(scope: string) {
|
||||||
|
this.scope = scope
|
||||||
|
this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initialize(): void {
|
||||||
|
// 子类可以覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
protected log(message: string, ...args: any[]): void {
|
||||||
|
this.logger.log(`[${this.scope}]`, message, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected error(message: string, error: Error): void {
|
||||||
|
this.logger.error(`[${this.scope}]`, message, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 文件预览服务
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/FilePreviewService.ts
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { ServiceBase } from '@/core/ServiceBase'
|
||||||
|
import type { FileApiService } from './FileApiService'
|
||||||
|
import { FileTypes } from '@/utils/fileTypeHelpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件预览服务
|
||||||
|
* 负责处理各种文件类型的预览逻辑
|
||||||
|
*/
|
||||||
|
export class FilePreviewService extends ServiceBase {
|
||||||
|
// ========== 状态(私有) ==========
|
||||||
|
private readonly _previewUrl = ref<string>('')
|
||||||
|
private readonly _imageLoading = ref<boolean>(false)
|
||||||
|
private readonly _currentImageDimensions = ref<string>('')
|
||||||
|
|
||||||
|
// ========== 依赖注入 ==========
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly filePath: Ref<string>
|
||||||
|
) {
|
||||||
|
super('FilePreviewService')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共接口(访问器) ==========
|
||||||
|
|
||||||
|
/** 获取预览URL(响应式) */
|
||||||
|
get previewUrl(): Ref<string> {
|
||||||
|
return this._previewUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取图片加载状态(响应式) */
|
||||||
|
get imageLoading(): Ref<boolean> {
|
||||||
|
return this._imageLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取图片尺寸(响应式) */
|
||||||
|
get currentImageDimensions(): Ref<string> {
|
||||||
|
return this._currentImageDimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预览URL
|
||||||
|
*/
|
||||||
|
updatePreviewUrl(url: string): void {
|
||||||
|
this._previewUrl.value = url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览文件
|
||||||
|
*/
|
||||||
|
async previewFile(filePath: string): Promise<void> {
|
||||||
|
const ext = FileTypes.getExtension(filePath)
|
||||||
|
|
||||||
|
if (this.isImageFile(filePath)) {
|
||||||
|
await this.previewImage(filePath)
|
||||||
|
} else if (this.isVideoFile(filePath)) {
|
||||||
|
await this.previewVideo(filePath)
|
||||||
|
} else if (this.isAudioFile(filePath)) {
|
||||||
|
await this.previewAudio(filePath)
|
||||||
|
} else if (this.isPdfFile(filePath)) {
|
||||||
|
await this.previewPdf(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为图片文件
|
||||||
|
*/
|
||||||
|
isImageFile(path: string): boolean {
|
||||||
|
return FileTypes.isImage(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为视频文件
|
||||||
|
*/
|
||||||
|
isVideoFile(path: string): boolean {
|
||||||
|
return FileTypes.isVideo(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为音频文件
|
||||||
|
*/
|
||||||
|
isAudioFile(path: string): boolean {
|
||||||
|
return FileTypes.isAudio(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为PDF文件
|
||||||
|
*/
|
||||||
|
isPdfFile(path: string): boolean {
|
||||||
|
return FileTypes.isPdf(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为HTML文件
|
||||||
|
*/
|
||||||
|
isHtmlFile(path: string): boolean {
|
||||||
|
return FileTypes.isHtml(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为Markdown文件
|
||||||
|
*/
|
||||||
|
isMarkdownFile(path: string): boolean {
|
||||||
|
return FileTypes.isMarkdown(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否支持编辑/预览切换
|
||||||
|
*/
|
||||||
|
isEditableWithPreview(filename: string): boolean {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||||
|
return ['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
|
private async previewImage(path: string): Promise<void> {
|
||||||
|
this._imageLoading.value = true
|
||||||
|
try {
|
||||||
|
const url = await this.fileApi.getImageUrl(path)
|
||||||
|
this.updatePreviewUrl(url)
|
||||||
|
this.log('图片预览加载成功', path)
|
||||||
|
} catch (error) {
|
||||||
|
this.error('图片预览加载失败', error as Error)
|
||||||
|
Message.error('图片加载失败')
|
||||||
|
} finally {
|
||||||
|
this._imageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async previewVideo(path: string): Promise<void> {
|
||||||
|
const url = await this.fileApi.getFileUrl(path)
|
||||||
|
this.updatePreviewUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async previewAudio(path: string): Promise<void> {
|
||||||
|
const url = await this.fileApi.getFileUrl(path)
|
||||||
|
this.updatePreviewUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async previewPdf(path: string): Promise<void> {
|
||||||
|
const url = await this.fileApi.getFileUrl(path)
|
||||||
|
this.updatePreviewUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ZIP浏览服务
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/ZipBrowserService.ts
|
||||||
|
import { ref, computed, type Ref } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { ServiceBase } from '@/core/ServiceBase'
|
||||||
|
import type { FileApiService } from './FileApiService'
|
||||||
|
import type { FilePreviewService } from './FilePreviewService'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP浏览服务
|
||||||
|
* 负责ZIP文件浏览逻辑
|
||||||
|
*/
|
||||||
|
export class ZipBrowserService extends ServiceBase {
|
||||||
|
// ========== 状态 ==========
|
||||||
|
private readonly _isBrowsingZip = ref<boolean>(false)
|
||||||
|
private readonly _currentZipPath = ref<string>('')
|
||||||
|
private readonly _currentZipDirectory = ref<string>('')
|
||||||
|
private readonly _pathBeforeZip = ref<string>('')
|
||||||
|
|
||||||
|
// ========== 依赖注入 ==========
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly previewService: FilePreviewService,
|
||||||
|
private readonly fileList: Ref<FileItem[]>,
|
||||||
|
private readonly filePath: Ref<string>
|
||||||
|
) {
|
||||||
|
super('ZipBrowserService')
|
||||||
|
// 构造函数保证依赖已初始化
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 计算属性 ==========
|
||||||
|
|
||||||
|
/** 是否正在浏览ZIP */
|
||||||
|
get isBrowsingZip(): Ref<boolean> {
|
||||||
|
return this._isBrowsingZip
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前ZIP路径 */
|
||||||
|
get currentZipPath(): Ref<string> {
|
||||||
|
return this._currentZipPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示路径 */
|
||||||
|
get displayPath(): Ref<string> {
|
||||||
|
return computed(() => {
|
||||||
|
if (this._currentZipDirectory.value) {
|
||||||
|
return `📦 ${this._currentZipPath.value} [${this._currentZipDirectory.value}]`
|
||||||
|
}
|
||||||
|
return `📦 ${this._currentZipPath.value}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入ZIP浏览模式
|
||||||
|
*/
|
||||||
|
async enterZipMode(zipPath: string): Promise<void> {
|
||||||
|
this._pathBeforeZip.value = this.filePath.value
|
||||||
|
this._currentZipPath.value = zipPath
|
||||||
|
this._isBrowsingZip.value = true
|
||||||
|
this._currentZipDirectory.value = ''
|
||||||
|
|
||||||
|
await this.loadZipDirectory()
|
||||||
|
this.log('进入ZIP浏览模式', zipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出ZIP浏览模式
|
||||||
|
*/
|
||||||
|
exitZipMode(): void {
|
||||||
|
this._isBrowsingZip.value = false
|
||||||
|
this._currentZipPath.value = ''
|
||||||
|
this._currentZipDirectory.value = ''
|
||||||
|
this.log('退出ZIP浏览模式')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取ZIP文件名
|
||||||
|
*/
|
||||||
|
getZipFileName(zipPath: string): string {
|
||||||
|
const parts = zipPath.split(/[/\\]/)
|
||||||
|
return parts[parts.length - 1] || zipPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取面包屑
|
||||||
|
*/
|
||||||
|
getZipBreadcrumbs(): ZipBreadcrumbItem[] {
|
||||||
|
const crumbs: ZipBreadcrumbItem[] = [
|
||||||
|
{ name: this.getZipFileName(this._currentZipPath.value), path: '' }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this._currentZipDirectory.value) {
|
||||||
|
const parts = this._currentZipDirectory.value.split('/')
|
||||||
|
let currentPath = ''
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath += (currentPath ? '/' : '') + part
|
||||||
|
crumbs.push({ name: part, path: currentPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航到指定目录
|
||||||
|
*/
|
||||||
|
async navigateToZipDirectory(path: string): Promise<void> {
|
||||||
|
this._currentZipDirectory.value = path
|
||||||
|
await this.loadZipDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 私有方法 ==========
|
||||||
|
|
||||||
|
private async loadZipDirectory(): Promise<void> {
|
||||||
|
// 加载逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 文件系统服务(聚合服务)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/FileSystemService.ts
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { ServiceBase } from '@/core/ServiceBase'
|
||||||
|
import { FilePreviewService } from './FilePreviewService'
|
||||||
|
import { ZipBrowserService } from './ZipBrowserService'
|
||||||
|
import { FileEditService } from './FileEditService'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件系统服务(门面)
|
||||||
|
* 聚合所有文件相关服务
|
||||||
|
*/
|
||||||
|
export class FileSystemService extends ServiceBase {
|
||||||
|
// ========== 状态 ==========
|
||||||
|
private readonly _fileList = ref<FileItem[]>([])
|
||||||
|
private readonly _fileLoading = ref<boolean>(false)
|
||||||
|
private readonly _selectedFile = ref<FileItem | null>(null)
|
||||||
|
|
||||||
|
// ========== 子服务(依赖注入) ==========
|
||||||
|
readonly preview: FilePreviewService
|
||||||
|
readonly zip: ZipBrowserService
|
||||||
|
readonly edit: FileEditService
|
||||||
|
|
||||||
|
// ========== 构造函数(保证初始化顺序) ==========
|
||||||
|
constructor(
|
||||||
|
private readonly fileApi: FileApiService,
|
||||||
|
private readonly filePath: Ref<string>
|
||||||
|
) {
|
||||||
|
super('FileSystemService')
|
||||||
|
|
||||||
|
// 按顺序初始化子服务
|
||||||
|
// 1. 预览服务(无依赖)
|
||||||
|
this.preview = new FilePreviewService(this.fileApi, this.filePath)
|
||||||
|
|
||||||
|
// 2. 编辑服务(依赖预览服务)
|
||||||
|
this.edit = new FileEditService(this.fileApi, this.preview)
|
||||||
|
|
||||||
|
// 3. ZIP服务(依赖预览服务)
|
||||||
|
this.zip = new ZipBrowserService(
|
||||||
|
this.fileApi,
|
||||||
|
this.preview,
|
||||||
|
this._fileList,
|
||||||
|
this.filePath
|
||||||
|
)
|
||||||
|
|
||||||
|
this.log('文件系统服务初始化完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公共接口 ==========
|
||||||
|
|
||||||
|
/** 文件列表(响应式) */
|
||||||
|
get fileList(): Ref<FileItem[]> {
|
||||||
|
return this._fileList
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载状态(响应式) */
|
||||||
|
get fileLoading(): Ref<boolean> {
|
||||||
|
return this._fileLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选中文件(响应式) */
|
||||||
|
get selectedFile(): Ref<FileItem | null> {
|
||||||
|
return this._selectedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载目录
|
||||||
|
*/
|
||||||
|
async loadDirectory(path: string): Promise<void> {
|
||||||
|
this._fileLoading.value = true
|
||||||
|
try {
|
||||||
|
const files = await this.fileApi.listDirectory(path)
|
||||||
|
this._fileList.value = files
|
||||||
|
this.log('目录加载成功', path, files.length, '个文件')
|
||||||
|
} catch (error) {
|
||||||
|
this.error('目录加载失败', error as Error)
|
||||||
|
Message.error('加载目录失败')
|
||||||
|
} finally {
|
||||||
|
this._fileLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览文件
|
||||||
|
*/
|
||||||
|
async previewFile(file: FileItem): Promise<void> {
|
||||||
|
if (this.zip.isBrowsingZip.value) {
|
||||||
|
await this.zip.previewZipFile(file.path)
|
||||||
|
} else {
|
||||||
|
await this.preview.previewFile(file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Composable 适配器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useFileSystem.ts
|
||||||
|
import { createSingleton } from '@/utils/singleton'
|
||||||
|
import { FileSystemService } from '@/services/FileSystemService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件系统 Composable
|
||||||
|
* 轻量级适配器,桥接 Vue 响应式系统和服务层
|
||||||
|
*/
|
||||||
|
export function useFileSystem(options: UseFileSystemOptions = {}) {
|
||||||
|
// 创建或获取服务单例
|
||||||
|
const service = createSingleton(() => {
|
||||||
|
const fileApi = new FileApiService()
|
||||||
|
const filePath = ref(options.initialPath || '')
|
||||||
|
return new FileSystemService(fileApi, filePath)
|
||||||
|
}, 'fileSystemService')
|
||||||
|
|
||||||
|
// 返回响应式接口
|
||||||
|
return {
|
||||||
|
// 状态(直接返回 ref)
|
||||||
|
fileList: service.fileList,
|
||||||
|
fileLoading: service.fileLoading,
|
||||||
|
selectedFile: service.selectedFile,
|
||||||
|
|
||||||
|
// 方法(委托给服务)
|
||||||
|
loadDirectory: (path: string) => service.loadDirectory(path),
|
||||||
|
previewFile: (file: FileItem) => service.previewFile(file),
|
||||||
|
|
||||||
|
// 类型判断(委托给预览服务)
|
||||||
|
isImageFile: (path: string) => service.preview.isImageFile(path),
|
||||||
|
isVideoFile: (path: string) => service.preview.isVideoFile(path),
|
||||||
|
isPdfFile: (path: string) => service.preview.isPdfFile(path),
|
||||||
|
|
||||||
|
// 服务实例(可选,用于高级用法)
|
||||||
|
$service: service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 在 Vue 中使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// index.vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useFileSystem } from './composables/useFileSystem'
|
||||||
|
|
||||||
|
// 简单使用(和之前一样)
|
||||||
|
const {
|
||||||
|
fileList,
|
||||||
|
fileLoading,
|
||||||
|
loadDirectory,
|
||||||
|
previewFile
|
||||||
|
} = useFileSystem()
|
||||||
|
|
||||||
|
// 或者访问完整服务
|
||||||
|
const { $service } = useFileSystem()
|
||||||
|
|
||||||
|
// 可以访问所有服务方法
|
||||||
|
$service.preview.updatePreviewUrl(url)
|
||||||
|
$service.zip.enterZipMode(path)
|
||||||
|
|
||||||
|
// Computed 配置
|
||||||
|
const fileEditorPanelConfig = computed(() => ({
|
||||||
|
// 使用服务方法,不会出现初始化问题
|
||||||
|
isImageView: $service.preview.isImageFile(currentFileName),
|
||||||
|
canPreviewFile: $service.preview.isEditableWithPreview(currentFileName),
|
||||||
|
// ...
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 方案优势
|
||||||
|
|
||||||
|
### 1. 解决初始化顺序问题
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 之前:依赖手动保证顺序
|
||||||
|
const config = computed(() => useHelper())
|
||||||
|
const useHelper = () => { ... } // 太晚了
|
||||||
|
|
||||||
|
// ✅ 现在:构造函数保证顺序
|
||||||
|
class Service {
|
||||||
|
constructor(helper: HelperService) { // 必须先创建 helper
|
||||||
|
this.helper = helper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 依赖注入,避免循环依赖
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class FileSystemService {
|
||||||
|
constructor(
|
||||||
|
private preview: FilePreviewService, // 先初始化
|
||||||
|
private zip: ZipBrowserService // 可以依赖 preview
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 高内聚,状态和行为绑定
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class FilePreviewService {
|
||||||
|
private _previewUrl = ref('') // 状态
|
||||||
|
|
||||||
|
updatePreviewUrl(url: string) { // 行为
|
||||||
|
this._previewUrl.value = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 类型安全,IDE 友好
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const service = new FilePreviewService(...)
|
||||||
|
service. // IDE 自动提示所有公共方法
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 易于测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Mock 依赖
|
||||||
|
const mockApi = new MockFileApiService()
|
||||||
|
const service = new FilePreviewService(mockApi, filePath)
|
||||||
|
|
||||||
|
// 测试方法
|
||||||
|
expect(service.isImageFile('test.jpg')).toBe(true)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 实施步骤
|
||||||
|
|
||||||
|
### 阶段1:创建服务层(1周)
|
||||||
|
|
||||||
|
1. 创建核心基类 `ServiceBase`
|
||||||
|
2. 实现 `FilePreviewService`
|
||||||
|
3. 实现 `ZipBrowserService`
|
||||||
|
4. 实现 `FileSystemService`
|
||||||
|
|
||||||
|
### 阶段2:创建适配器(3天)
|
||||||
|
|
||||||
|
1. 实现 `useFileSystem` composable
|
||||||
|
2. 确保向后兼容
|
||||||
|
|
||||||
|
### 阶段3:迁移功能(2周)
|
||||||
|
|
||||||
|
1. 逐步迁移现有功能到服务层
|
||||||
|
2. 保持老代码可用
|
||||||
|
3. 充分测试
|
||||||
|
|
||||||
|
### 阶段4:清理优化(1周)
|
||||||
|
|
||||||
|
1. 移除旧的 composables
|
||||||
|
2. 优化性能
|
||||||
|
3. 完善文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 总结
|
||||||
|
|
||||||
|
### 当前问题的根本原因
|
||||||
|
|
||||||
|
Composition API + 函数式方案**无法从架构层面**保证初始化顺序,只能依赖开发者手动保证。
|
||||||
|
|
||||||
|
### OOP 方案的核心优势
|
||||||
|
|
||||||
|
**构造函数 + 依赖注入**可以从编译器和运行时两个层面保证初始化顺序。
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
|
||||||
|
立即采用 OOP 服务层方案,彻底解决初始化顺序问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**生成时间**: 2026-01-31
|
||||||
|
**预计工作量**: 3-4周
|
||||||
|
**风险等级**: 中等(需要重构,但可以渐进式迁移)
|
||||||
15
docs/02-架构设计/OOP架构/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# OOP 架构设计文档
|
||||||
|
|
||||||
|
本目录包含面向对象编程(OOP)架构设计的分析和方案文档。
|
||||||
|
|
||||||
|
## 📄 文档列表
|
||||||
|
|
||||||
|
- [OOP-vs-Composables架构对比.md](./OOP-vs-Composables架构对比.md) - OOP 与 Composables 架构对比
|
||||||
|
- [OOP-Composition组合方案.md](./OOP-Composition组合方案.md) - OOP Composition 组合方案
|
||||||
|
- [OOP服务层实施方案.md](./OOP服务层实施方案.md) - OOP 服务层实施方案
|
||||||
|
- [全部OOP的理性分析.md](./全部OOP的理性分析.md) - 全面 OOP 的理性分析
|
||||||
|
- [临时解决方案-OOP重写ZIP.md](./临时解决方案-OOP重写ZIP.md) - 临时解决方案
|
||||||
|
|
||||||
|
## 🎯 设计目标
|
||||||
|
|
||||||
|
探索使用 OOP 模式替代 Composition API 的可行性,提供更清晰的代码组织结构。
|
||||||