diff --git a/.cursor/rules/build-packaging.mdc b/.cursor/rules/build-packaging.mdc new file mode 100644 index 0000000..36fba5d --- /dev/null +++ b/.cursor/rules/build-packaging.mdc @@ -0,0 +1,44 @@ +--- +description: Windows 构建与打包规范:必须用 wails3 task,禁止裸 go build 出包 +alwaysApply: true +--- + +# 构建与打包(Windows) + +## 出包必须用官方任务,禁止裸 `go build` + +```powershell +# ✅ 正确:产出 bin/code-count.exe(带图标、版本信息、无控制台窗口) +wails3 task windows:build + +# ✅ 安装包(NSIS,内部会先执行上面的 build) +wails3 task package + +# ❌ 错误:裸 go build 出的 exe 没有图标、双击会闪控制台黑框 +go build -o bin/code-count.exe . +``` + +原因:图标不是自动带上的。官方任务先执行 `wails3 generate syso`,把 +`build/windows/icon.ico` + `build/windows/info.json`(版本信息) + +`wails.exe.manifest` 编成 `wails_windows_amd64.syso` 链接进 exe, +构建完会删除 `*.syso`,所以仓库里平时看不到这个文件。 +production 构建还带 `-tags production -ldflags "-w -s -H windowsgui"`。 + +裸 `go build` 仅可用于快速验证编译通过(如 `go vet` / `go test` 前置检查), +产物不得交付给用户。 + +## 换 logo 的流程 + +1. 替换 `build/appicon.png`(源头,正方形 PNG)。 +2. 跑 `wails3 task common:generate:icons` 重新生成 `build/windows/icon.ico` + 与 `build/darwin/icons.icns`(该任务按 appicon.png 的 checksum 缓存, + 没改动会显示 up to date)。 +3. 重新 `wails3 task windows:build`。 +4. 验证:`powershell -ExecutionPolicy Bypass -File tools\extract-icon.ps1` + 会从 exe 提取图标存成 `tools/exe-icon-check.png`,肉眼确认。 +5. 资源管理器仍显示旧图标属 Windows 图标缓存问题,改名或重启 explorer 即可。 + +## 开发运行 + +- 热更开发:`wails3 dev -config ./build/config.yml -port 9245`(即 `wails3 task dev`)。 +- 只改前端时,`wails3 task windows:build` 会自动 npm build 并嵌入,无需手动。 diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm- new file mode 100644 index 0000000..3394be1 --- /dev/null +++ b/.task/checksum/build-frontend--DEV--RUNNER-npm- @@ -0,0 +1 @@ +cedbecabd994aac478c9127719ea7b09 diff --git a/.task/checksum/common-generate-icons b/.task/checksum/common-generate-icons new file mode 100644 index 0000000..9201664 --- /dev/null +++ b/.task/checksum/common-generate-icons @@ -0,0 +1 @@ +b49e83b187f47c4d3f792713163199ef diff --git a/.task/checksum/windows-common-generate-bindings b/.task/checksum/windows-common-generate-bindings new file mode 100644 index 0000000..d5014ce --- /dev/null +++ b/.task/checksum/windows-common-generate-bindings @@ -0,0 +1 @@ +4fa5aa6193227681772cfc8412c2ad90 diff --git a/.task/checksum/windows-common-generate-icons b/.task/checksum/windows-common-generate-icons new file mode 100644 index 0000000..9201664 --- /dev/null +++ b/.task/checksum/windows-common-generate-icons @@ -0,0 +1 @@ +b49e83b187f47c4d3f792713163199ef diff --git a/.task/checksum/windows-common-install-frontend-deps-npm b/.task/checksum/windows-common-install-frontend-deps-npm new file mode 100644 index 0000000..731ec04 --- /dev/null +++ b/.task/checksum/windows-common-install-frontend-deps-npm @@ -0,0 +1 @@ +5d6a0612785e17429ea40e69d09a5b14 diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..433a6aa --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,37 @@ +version: '3' + +vars: + APP_NAME: "code-count" + BIN_DIR: "bin" + PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}' + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + # Target OS for build/package/run. Defaults to the host OS, and is overridden + # by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. + GOOS: '{{.GOOS | default OS}}' + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + +tasks: + build: + summary: Builds the application + cmds: + - task: "{{.GOOS}}:build" + + package: + summary: Packages a production build of the application + cmds: + - task: "{{.GOOS}}:package" + + run: + summary: Runs the application + cmds: + - task: "{{.GOOS}}:run" + + dev: + summary: Runs the application in development mode + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} diff --git a/ai.go b/ai.go new file mode 100644 index 0000000..08a7b24 --- /dev/null +++ b/ai.go @@ -0,0 +1,803 @@ +package main + +// ai.go 实现 AI 分析编排:会话/消息存储、场景化提示词构建、 +// 通过 service/ai 工厂创建 Provider 并以 "ai:stream" 事件流式推送回复。 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "view/service/ai" +) + +// ---------- 会话与消息存储 ---------- + +func (s *Store) ListAIConversations(projectID int64) ([]AIConversation, error) { + q := `SELECT c.id,c.project_id,COALESCE(p.name,''),c.provider,c.title,c.created_at,c.updated_at + FROM ai_conversations c LEFT JOIN projects p ON p.id=c.project_id` + args := []any{} + if projectID > 0 { + q += ` WHERE c.project_id=?` + args = append(args, projectID) + } + q += ` ORDER BY c.updated_at DESC LIMIT 200` + rows, e := s.db.Query(q, args...) + if e != nil { + return nil, e + } + defer rows.Close() + out := []AIConversation{} + for rows.Next() { + var x AIConversation + if e = rows.Scan(&x.ID, &x.ProjectID, &x.ProjectName, &x.Provider, &x.Title, &x.CreatedAt, &x.UpdatedAt); e != nil { + return nil, e + } + out = append(out, x) + } + return out, rows.Err() +} + +func (s *Store) CreateAIConversation(projectID int64, provider, title string) (AIConversation, error) { + now := nowRFC() + res, e := s.db.Exec(`INSERT INTO ai_conversations(project_id,provider,title,created_at,updated_at) VALUES(?,?,?,?,?)`, + projectID, provider, title, now, now) + if e != nil { + return AIConversation{}, e + } + id, _ := res.LastInsertId() + var name string + _ = s.db.QueryRow(`SELECT name FROM projects WHERE id=?`, projectID).Scan(&name) + return AIConversation{ID: id, ProjectID: projectID, ProjectName: name, Provider: provider, Title: title, CreatedAt: now, UpdatedAt: now}, nil +} + +func (s *Store) TouchAIConversation(id int64, provider string) { + _, _ = s.db.Exec(`UPDATE ai_conversations SET updated_at=?,provider=? WHERE id=?`, nowRFC(), provider, id) +} + +func (s *Store) DeleteAIConversation(id int64) error { + if _, e := s.db.Exec(`DELETE FROM ai_messages WHERE conversation_id=?`, id); e != nil { + return e + } + _, e := s.db.Exec(`DELETE FROM ai_conversations WHERE id=?`, id) + return e +} + +func (s *Store) ListAIMessages(conversationID int64) ([]AIMessage, error) { + rows, e := s.db.Query(`SELECT id,conversation_id,role,content,created_at FROM ai_messages WHERE conversation_id=? ORDER BY id`, conversationID) + if e != nil { + return nil, e + } + defer rows.Close() + out := []AIMessage{} + for rows.Next() { + var x AIMessage + if e = rows.Scan(&x.ID, &x.ConversationID, &x.Role, &x.Content, &x.CreatedAt); e != nil { + return nil, e + } + out = append(out, x) + } + return out, rows.Err() +} + +func (s *Store) AddAIMessage(conversationID int64, role, content string) (AIMessage, error) { + now := nowRFC() + res, e := s.db.Exec(`INSERT INTO ai_messages(conversation_id,role,content,created_at) VALUES(?,?,?,?)`, conversationID, role, content, now) + if e != nil { + return AIMessage{}, e + } + id, _ := res.LastInsertId() + return AIMessage{ID: id, ConversationID: conversationID, Role: role, Content: content, CreatedAt: now}, nil +} + +// ---------- 绑定接口 ---------- + +func (a *App) ListAIConversations(projectID int64) ([]AIConversation, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.ListAIConversations(projectID) +} + +func (a *App) GetAIMessages(conversationID int64) ([]AIMessage, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.ListAIMessages(conversationID) +} + +func (a *App) DeleteAIConversation(id int64) error { + if e := a.ready(); e != nil { + return e + } + a.stopAIStream(id) + return a.store.DeleteAIConversation(id) +} + +// StopAIStream 中止指定会话正在进行的流式回复。 +func (a *App) StopAIStream(conversationID int64) error { + a.stopAIStream(conversationID) + return nil +} + +func (a *App) stopAIStream(conversationID int64) { + a.mu.Lock() + cancel := a.aiStreams[conversationID] + delete(a.aiStreams, conversationID) + a.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// aiStreamEvent 是 "ai:stream" 事件载荷。 +type aiStreamEvent struct { + ConversationID int64 `json:"conversationId"` + Delta string `json:"delta,omitempty"` + Done bool `json:"done,omitempty"` + Error string `json:"error,omitempty"` + MessageID int64 `json:"messageId,omitempty"` +} + +// SendAIMessage 发送一条用户消息并启动流式回复。 +// conversationID 为 0 时自动创建会话;scenario: chat | project | git | todo | ticket。 +func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content string) (AIConversation, error) { + if e := a.ready(); e != nil { + return AIConversation{}, e + } + content = strings.TrimSpace(content) + if content == "" { + return AIConversation{}, errors.New("AI_EMPTY_MESSAGE") + } + st, e := a.store.Settings() + if e != nil { + return AIConversation{}, e + } + provider, e := a.aiProvider() + if e != nil { + return AIConversation{}, e + } + + var conv AIConversation + if conversationID == 0 { + title := content + if r := []rune(title); len(r) > 40 { + title = string(r[:40]) + "…" + } + conv, e = a.store.CreateAIConversation(projectID, provider.Name(), title) + if e != nil { + return AIConversation{}, e + } + } else { + convs, _ := a.store.ListAIConversations(0) + for _, c := range convs { + if c.ID == conversationID { + conv = c + break + } + } + if conv.ID == 0 { + return AIConversation{}, errors.New("AI_CONVERSATION_NOT_FOUND") + } + projectID = conv.ProjectID + } + + a.mu.Lock() + if _, running := a.aiStreams[conv.ID]; running { + a.mu.Unlock() + return conv, errors.New("AI_STREAM_RUNNING") + } + ctx, cancel := context.WithCancel(a.ctx) + a.aiStreams[conv.ID] = cancel + a.mu.Unlock() + + // 组装消息:场景系统提示词 + 最近历史 + 本条用户消息。 + history, _ := a.store.ListAIMessages(conv.ID) + msgs := []ai.Message{{Role: "system", Content: a.buildAIContext(projectID, scenario, st.Locale)}} + if len(history) > 20 { + history = history[len(history)-20:] + } + for _, m := range history { + msgs = append(msgs, ai.Message{Role: m.Role, Content: m.Content}) + } + msgs = append(msgs, ai.Message{Role: "user", Content: content}) + if _, e = a.store.AddAIMessage(conv.ID, "user", content); e != nil { + a.stopAIStream(conv.ID) + return conv, e + } + a.store.TouchAIConversation(conv.ID, provider.Name()) + + go a.runAIStream(ctx, provider, conv.ID, msgs) + return conv, nil +} + +// runAIStream 消费 Provider 流并转发到前端,结束后落库助手回复。 +func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int64, msgs []ai.Message) { + defer a.stopAIStream(convID) + stream, e := provider.ChatStream(ctx, msgs) + if e != nil { + a.store.Log("error", "AI", "AI 请求失败", e.Error()) + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true}) + return + } + var sb strings.Builder + for chunk := range stream { + if chunk.Err != nil { + // 已有部分内容则保留落库,方便用户继续。 + if sb.Len() > 0 { + if m, e2 := a.store.AddAIMessage(convID, "assistant", sb.String()); e2 == nil { + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true, MessageID: m.ID}) + return + } + } + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true}) + return + } + sb.WriteString(chunk.Content) + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Delta: chunk.Content}) + } + if ctx.Err() != nil && sb.Len() == 0 { + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: "AI_CANCELLED", Done: true}) + return + } + m, e := a.store.AddAIMessage(convID, "assistant", sb.String()) + if e != nil { + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true}) + return + } + a.store.TouchAIConversation(convID, provider.Name()) + a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID}) +} + +// ---------- 模块 AI 介绍(分析完成后异步生成,按项目+模块存最新一份) ---------- + +func (s *Store) GetAISummaries(projectID int64) ([]AISummary, error) { + // projectID=0 表示工作台全局简报(今日规划/下班日报),存于 day_briefs。 + q, args := `SELECT project_id,kind,content,provider,generated_at FROM ai_summaries WHERE project_id=?`, []any{projectID} + if projectID == 0 { + q, args = `SELECT 0,kind,content,provider,generated_at FROM day_briefs`, nil + } + rows, e := s.db.Query(q, args...) + if e != nil { + return nil, e + } + defer rows.Close() + out := []AISummary{} + for rows.Next() { + var x AISummary + if e = rows.Scan(&x.ProjectID, &x.Kind, &x.Content, &x.Provider, &x.GeneratedAt); e != nil { + return nil, e + } + // 兜底清理历史坏数据:早期版本可能存入了带围栏的原始回答。 + x.Content = stripFence(x.Content) + out = append(out, x) + } + return out, rows.Err() +} + +func (s *Store) SaveAISummary(projectID int64, kind, provider, content string) error { + if projectID == 0 { + _, e := s.db.Exec(`INSERT INTO day_briefs(kind,provider,content,generated_at) VALUES(?,?,?,?) + ON CONFLICT(kind) DO UPDATE SET content=excluded.content,provider=excluded.provider,generated_at=excluded.generated_at`, + kind, provider, content, nowRFC()) + return e + } + _, e := s.db.Exec(`INSERT INTO ai_summaries(project_id,kind,provider,content,generated_at) VALUES(?,?,?,?,?) + ON CONFLICT(project_id,kind) DO UPDATE SET content=excluded.content,provider=excluded.provider,generated_at=excluded.generated_at`, + projectID, kind, provider, content, nowRFC()) + return e +} + +func (a *App) GetAISummaries(projectID int64) ([]AISummary, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.GetAISummaries(projectID) +} + +// RegenerateAISummary 手动重新生成某个模块的 AI 介绍(后台执行,完成后发 ai:summary 事件)。 +func (a *App) RegenerateAISummary(projectID int64, kind string) error { + if e := a.ready(); e != nil { + return e + } + if kind != "project" && kind != "git" && kind != "structure" && kind != "insights" && kind != "dayplan" && kind != "dayreport" && !scopeSummaryKinds[kind] { + return errors.New("AI_SUMMARY_KIND_INVALID") + } + if _, e := a.aiProvider(); e != nil { + return e + } + go a.generateAISummaries(projectID, kind) + return nil +} + +// aiProvider 按当前设置创建 Provider;未配置 Key 时返回 AI_NO_KEY。 +func (a *App) aiProvider() (ai.Provider, error) { + st, e := a.store.Settings() + if e != nil { + return nil, e + } + key := st.SparkKey + if st.AIProvider == ai.ProviderDeepSeek { + key = st.DeepSeekKey + } + return ai.New(st.AIProvider, key) +} + +// aiSummarySem 全局串行生成,避免批量分析后并发打爆免费额度接口。 +var aiSummarySem = make(chan struct{}, 1) + +// aiSummaryEvent 是 "ai:summary" 事件载荷。 +type aiSummaryEvent struct { + ProjectID int64 `json:"projectId"` + Kind string `json:"kind"` + Error string `json:"error,omitempty"` +} + +// stripFence 去掉包裹全文的代码围栏(模型偶尔把整段回答包进 ```markdown 中, +// 甚至只输出开头围栏忘了闭合——这种孤立围栏也要剥掉,否则整段被渲染成代码块)。 +func stripFence(s string) string { + if !strings.HasPrefix(s, "```") { + return s + } + lines := strings.Split(s, "\n") + last := len(lines) - 1 + for last > 0 && strings.TrimSpace(lines[last]) == "" { + last-- + } + if last >= 1 && strings.TrimSpace(lines[last]) == "```" { + return strings.TrimSpace(strings.Join(lines[1:last], "\n")) + } + return strings.TrimSpace(strings.Join(lines[1:], "\n")) +} + +// dedentCommon 去除各行公共的行首空白,避免模型输出整体缩进被 Markdown 误判为代码块。 +func dedentCommon(s string) string { + lines := strings.Split(s, "\n") + common := -1 + for _, ln := range lines { + t := strings.TrimLeft(ln, " \t") + if t == "" { + continue + } + n := len(ln) - len(t) + if common < 0 || n < common { + common = n + } + if common == 0 { + return s + } + } + if common <= 0 { + return s + } + for i, ln := range lines { + if len(ln) >= common { + lines[i] = ln[common:] + } else { + lines[i] = strings.TrimLeft(ln, " \t") + } + } + return strings.Join(lines, "\n") +} + +// summaryInstruction 摘要指令:与聊天场景共用数据上下文,但任务改为生成简介。 +func summaryInstruction(kind, locale string) string { + if s, ok := scopeInstruction(kind, locale); ok { + return s + } + if locale == "en" { + switch kind { + case "git": + return "Based on the data above, write a concise Git overview of this project (within 120 words): collaboration structure, activity trend and risky hotspots. Output Markdown text only, no headings." + case "structure": + return "Based on the data above, write a concise overview of this project's directory layout (within 120 words): module organization, size distribution and large-file risks. Output Markdown text only, no headings." + case "insights": + return "Based on the data above, write a concise health-check summary (within 120 words): health score, main issue types and what to fix first. Output Markdown text only, no headings." + case "dayplan": + return "Based on the todos and tickets above, plan my work day: sort by priority and due time, split into Morning and Afternoon, one line per item with a short reason (overdue, due today, high priority); end with one line about the biggest risk. Output a Markdown list, no big headings." + case "dayreport": + return "Based on today's activity above, write a first-person end-of-day report (one paragraph, 100-200 words, ready to paste into a work chat): what I completed, what is in progress, and what carries over to tomorrow. Output plain text, no headings or lists." + } + return "Based on the data above, write a concise project introduction (within 120 words): what it is, tech stack, scale and code health. Output Markdown text only, no headings." + } + switch kind { + case "git": + return "基于以上数据,用不超过 150 字写一段该项目的 Git 协作介绍:贡献结构、活跃趋势、高风险热点。直接输出 Markdown 正文,不要标题。" + case "structure": + return "基于以上数据,用不超过 150 字介绍该项目的目录结构与文件组织:模块划分、体积分布、大文件风险。直接输出 Markdown 正文,不要标题。" + case "insights": + return "基于以上数据,用不超过 150 字总结该项目的健康检查结果:健康分、主要问题类型与整改优先级。直接输出 Markdown 正文,不要标题。" + case "dayplan": + return "基于以上待办与工单,为我制定今天的工作规划:按优先级和截止时间排序,分【上午】【下午】两个时段,每项一行并注明理由(如已逾期、今天截止、高优先级);最后用一行提示最需要警惕的风险。只能引用上面列出的条目,不要编造任务。直接输出 Markdown 列表,不要大标题。" + case "dayreport": + return "基于以上今天的工作记录,用第一人称写一段 100~200 字的下班日报(一个自然段,适合直接粘贴到工作群):概述今天完成了哪些事项、推进中的事项,以及遗留或需要明天跟进的内容。只能引用上面列出的记录,不要编造。直接输出正文,不要标题和列表。" + } + return "基于以上数据,用不超过 150 字写一段项目介绍:项目定位、技术栈、代码规模与质量状况。直接输出 Markdown 正文,不要标题。" +} + +// staticDayBrief 数据为空时的固定文案:避免模型在无数据时凭空编造任务。 +func (a *App) staticDayBrief(kind, locale string) (string, bool) { + switch kind { + case "dayplan": + todos, _ := a.store.ListTodos("all", 0) + tickets, _ := a.store.ListTickets("all", 0) + for _, x := range todos { + if x.Status != "done" { + return "", false + } + } + for _, x := range tickets { + if x.Status != "resolved" && x.Status != "closed" { + return "", false + } + } + if locale == "en" { + return "No open todos or tickets today — enjoy the free time or plan something new.", true + } + return "今天没有待处理的待办或工单,可以安排一些新的计划,或者好好休息一下。", true + case "dayreport": + today := time.Now().Format("2006-01-02") + todos, _ := a.store.ListTodos("all", 0) + tickets, _ := a.store.ListTickets("all", 0) + hasEvent := func(history string) bool { + var list []histEntry + if json.Unmarshal([]byte(history), &list) != nil { + return false + } + for _, h := range list { + if ts, e := time.Parse(time.RFC3339, h.At); e == nil && ts.Local().Format("2006-01-02") == today { + return true + } + } + return false + } + for _, x := range todos { + if hasEvent(x.History) { + return "", false + } + } + for _, x := range tickets { + if hasEvent(x.History) { + return "", false + } + } + if locale == "en" { + return "No todo or ticket activity recorded today, so there is nothing to report yet.", true + } + return "今天还没有记录到待办或工单的状态变化,暂时没有可总结的内容。", true + } + return "", false +} + +// generateAISummaries 依次为指定模块生成 AI 介绍并落库;未配置 Key 时静默跳过。 +func (a *App) generateAISummaries(projectID int64, kinds ...string) { + provider, e := a.aiProvider() + if e != nil { + return + } + st, _ := a.store.Settings() + aiSummarySem <- struct{}{} + defer func() { <-aiSummarySem }() + for _, kind := range kinds { + // 无数据的全局简报直接落固定文案,不浪费一次注定编造的模型调用。 + if content, ok := a.staticDayBrief(kind, st.Locale); ok { + if e := a.store.SaveAISummary(projectID, kind, "system", content); e == nil { + a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind}) + } + continue + } + msgs := []ai.Message{ + {Role: "system", Content: a.buildAIContext(projectID, kind, st.Locale)}, + {Role: "user", Content: summaryInstruction(kind, st.Locale)}, + } + ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute) + stream, se := provider.ChatStream(ctx, msgs) + if se != nil { + cancel() + a.store.Log("warning", "AI", "AI 模块介绍生成失败", se.Error()) + a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: se.Error()}) + continue + } + var sb strings.Builder + var streamErr error + for chunk := range stream { + if chunk.Err != nil { + streamErr = chunk.Err + break + } + sb.WriteString(chunk.Content) + } + cancel() + if streamErr != nil || sb.Len() == 0 { + msg := "empty response" + if streamErr != nil { + msg = streamErr.Error() + } + a.store.Log("warning", "AI", "AI 模块介绍生成失败", msg) + a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: msg}) + continue + } + if e := a.store.SaveAISummary(projectID, kind, provider.Name(), dedentCommon(stripFence(strings.TrimSpace(sb.String())))); e != nil { + a.store.Log("warning", "AI", "AI 模块介绍保存失败", e.Error()) + continue + } + a.store.Log("info", "AI", "AI 模块介绍已更新", fmt.Sprintf("project=%d kind=%s", projectID, kind)) + a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind}) + } +} + +// ---------- 场景化提示词 ---------- + +// dayEvent 是今天发生的一次状态变化(用于下班日报)。 +type dayEvent struct { + kind, title, project, status, at string +} + +// writeDayContext 聚合“今天”的待办/工单数据(dayplan 看待处理项,dayreport 看今天的轨迹)。 +func (a *App) writeDayContext(b *strings.Builder, scenario string) { + now := time.Now() + weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + today := now.Format("2006-01-02") + fmt.Fprintf(b, "\n## 今天\n- 日期:%s(%s),现在时间 %s\n", today, weekdays[int(now.Weekday())], now.Format("15:04")) + + todos, _ := a.store.ListTodos("all", 0) + tickets, _ := a.store.ListTickets("all", 0) + + if scenario == "dayplan" { + b.WriteString("\n## 待处理的待办\n") + n := 0 + for _, x := range todos { + if x.Status == "done" || n >= 25 { + continue + } + n++ + fmt.Fprintf(b, "- [%s|%s] %s(截止 %s,项目 %s)%s\n", x.Status, x.Priority, x.Title, orDash(x.DueAt), orDash(x.ProjectName), dueFlag(x.DueAt, today)) + } + if n == 0 { + b.WriteString("- 无\n") + } + b.WriteString("\n## 待处理的工单\n") + n = 0 + for _, x := range tickets { + if x.Status == "resolved" || x.Status == "closed" || n >= 25 { + continue + } + n++ + fmt.Fprintf(b, "- [%s|%s] %s(截止 %s,项目 %s)%s\n", x.Status, x.Priority, x.Title, orDash(x.DueAt), orDash(x.ProjectName), dueFlag(x.DueAt, today)) + } + if n == 0 { + b.WriteString("- 无\n") + } + return + } + + // dayreport:从生命周期轨迹里筛出今天发生的状态变化 + var events []dayEvent + collect := func(kind, title, project, history string) { + var list []histEntry + if json.Unmarshal([]byte(history), &list) != nil { + return + } + for _, h := range list { + ts, e := time.Parse(time.RFC3339, h.At) + if e != nil || ts.Local().Format("2006-01-02") != today { + continue + } + events = append(events, dayEvent{kind: kind, title: title, project: project, status: h.Status, at: ts.Local().Format("15:04")}) + } + } + for _, x := range todos { + collect("待办", x.Title, x.ProjectName, x.History) + } + for _, x := range tickets { + collect("工单", x.Title, x.ProjectName, x.History) + } + b.WriteString("\n## 今天的工作记录(按状态变化)\n") + if len(events) == 0 { + b.WriteString("- 今天没有记录到状态变化\n") + } + for i, ev := range events { + if i >= 40 { + break + } + fmt.Fprintf(b, "- %s %s「%s」→ %s(项目 %s)\n", ev.at, ev.kind, ev.title, ev.status, orDash(ev.project)) + } + b.WriteString("\n状态说明:open=新建/待开始,doing/in_progress=开始处理,done/resolved=完成,closed=关闭。\n") +} + +func orDash(s string) string { + if strings.TrimSpace(s) == "" { + return "无" + } + return s +} + +// dueFlag 给截止时间补充“已逾期/今天截止”标记,帮助模型排优先级。 +func dueFlag(due, today string) string { + if due == "" { + return "" + } + d := due + if len(d) > 10 { + d = d[:10] + } + switch { + case d < today: + return "【已逾期】" + case d == today: + return "【今天截止】" + } + return "" +} + +// buildAIContext 按场景聚合项目数据生成系统提示词。 +func (a *App) buildAIContext(projectID int64, scenario, locale string) string { + var b strings.Builder + if locale == "en" { + b.WriteString("You are the built-in AI analyst of 年糕崽崽 PMS, a local code statistics and project workbench app. Answer in English, be specific and actionable, use Markdown.\n") + } else { + b.WriteString("你是代码统计与项目工作台应用「年糕崽崽 PMS」的内置 AI 分析师。请用中文回答,结论要具体、可执行,使用 Markdown 排版。\n") + } + // 工作台全局场景:不依赖具体项目,聚合今天的待办与工单。 + if scenario == "dayplan" || scenario == "dayreport" { + a.writeDayContext(&b, scenario) + return b.String() + } + // 页面级全局总结:启动台 / 日志 / 日历 / 配置 / 笔记。 + if scopeSummaryKinds[scenario] { + a.writeScopeContext(&b, scenario) + return b.String() + } + if projectID <= 0 { + return b.String() + } + p, e := a.store.GetProject(projectID) + if e != nil || p.ID == 0 { + return b.String() + } + fmt.Fprintf(&b, "\n## 项目基础信息\n- 名称: %s\n- 路径: %s\n- 描述: %s\n- 代码行: %d(代码 %d / 注释 %d / 空行 %d)\n- 文件数: %d\n- 提交数: %d,贡献者: %d\n- 最近分析: %s\n", + p.Name, p.Path, p.Description, p.Stats.TotalLines, p.Stats.CodeLines, p.Stats.CommentLines, p.Stats.BlankLines, p.Stats.FileCount, p.Stats.CommitCount, p.Stats.ContributorCount, p.Stats.LastAnalyzed) + + if len(p.Languages) > 0 { + b.WriteString("\n## 语言分布\n") + for i, l := range p.Languages { + if i >= 12 { + break + } + fmt.Fprintf(&b, "- %s: %d 文件, 代码 %d 行, 注释 %d 行\n", l.Name, l.Files, l.Code, l.Comments) + } + } + + switch scenario { + case "project": + if st, e := a.store.Structure(projectID); e == nil { + b.WriteString("\n## 目录结构概览\n") + for i, f := range st.Folders { + if i >= 10 { + break + } + fmt.Fprintf(&b, "- 目录 %s: %d 文件, %d 字节\n", f.Name, f.Files, f.Size) + } + for i, f := range st.LargeFiles { + if i >= 8 { + break + } + fmt.Fprintf(&b, "- 大文件 %s: %d 字节\n", f.Path, f.Size) + } + } + if ins, e := a.store.Insights(projectID); e == nil && len(ins.Issues) > 0 { + fmt.Fprintf(&b, "\n## 静态洞察(健康分 %d)\n", ins.HealthScore) + for i, x := range ins.Issues { + if i >= 12 { + break + } + fmt.Fprintf(&b, "- [%s] %s:%s(%s)\n", x.Severity, x.Title, x.Detail, x.Path) + } + } + b.WriteString("\n任务:基于以上数据分析该项目的技术栈、代码规模与质量风险,并给出改进建议。\n") + case "git": + if g, e := a.store.GitStats(projectID); e == nil { + fmt.Fprintf(&b, "\n## Git 概览\n- 分支: %s,提交 %d,+%d/-%d 行\n", g.CurrentBranch, g.CommitCount, g.Added, g.Deleted) + b.WriteString("\n## 贡献者\n") + for i, c := range g.Contributors { + if i >= 10 { + break + } + fmt.Fprintf(&b, "- %s: %d 次提交, +%d/-%d\n", c.Name, c.Commits, c.Added, c.Deleted) + } + b.WriteString("\n## 最近提交\n") + for i, c := range g.Commits { + if i >= 15 { + break + } + fmt.Fprintf(&b, "- %s %s(%s)\n", c.Date, c.Message, c.Author) + } + if len(g.Hotspots) > 0 { + b.WriteString("\n## 高频变更文件\n") + for i, h := range g.Hotspots { + if i >= 10 { + break + } + fmt.Fprintf(&b, "- %s: %d 次变更, +%d/-%d\n", h.Path, h.Changes, h.Added, h.Deleted) + } + } + } + b.WriteString("\n任务:分析该项目的 Git 贡献情况:贡献结构是否健康、活跃度趋势、高风险热点文件,并给出协作改进建议。\n") + case "structure": + if st, e := a.store.Structure(projectID); e == nil { + fmt.Fprintf(&b, "\n## 目录结构\n- 总文件 %d,总目录 %d,总大小 %d 字节\n", st.TotalFiles, st.TotalDirs, st.TotalSize) + for i, f := range st.Folders { + if i >= 14 { + break + } + fmt.Fprintf(&b, "- 目录 %s: %d 文件, %d 字节\n", f.Name, f.Files, f.Size) + } + if len(st.LargeFiles) > 0 { + b.WriteString("\n## 大文件\n") + for i, f := range st.LargeFiles { + if i >= 10 { + break + } + fmt.Fprintf(&b, "- %s: %d 字节\n", f.Path, f.Size) + } + } + if len(st.Extensions) > 0 { + b.WriteString("\n## 扩展名分布\n") + for i, x := range st.Extensions { + if i >= 12 { + break + } + fmt.Fprintf(&b, "- %s: %d 文件, %d 字节\n", x.Extension, x.Files, x.Size) + } + } + } + b.WriteString("\n任务:分析该项目的目录组织与模块划分是否合理,指出体积集中点与大文件风险,并给出精简建议。\n") + case "insights": + ins, e := a.store.Insights(projectID) + if e != nil { + // 从未做过检查时现算一次,避免 AI 在无数据下臆造 + ins, e = a.RefreshProjectInsights(projectID) + } + if e == nil { + fmt.Fprintf(&b, "\n## 静态检查(健康分 %d)\n- 高危 %d / 中危 %d / 低危 %d,TODO 标记 %d,超长文件 %d,大文件 %d\n", + ins.HealthScore, ins.Summary.High, ins.Summary.Medium, ins.Summary.Low, ins.Summary.TodoCount, ins.Summary.LongFiles, ins.Summary.LargeFiles) + for i, x := range ins.Issues { + if i >= 20 { + break + } + fmt.Fprintf(&b, "- [%s|%s] %s:%s(%s)建议:%s\n", x.Severity, x.Type, x.Title, x.Detail, x.Path, x.Suggestion) + } + } + b.WriteString("\n任务:解读这些检查结果:整体健康状况、最需要优先处理的问题类别与整改顺序建议。\n") + case "todo": + todos, _ := a.store.ListTodos("all", projectID) + b.WriteString("\n## 项目待办\n") + if len(todos) == 0 { + b.WriteString("(暂无待办)\n") + } + for i, t := range todos { + if i >= 30 { + break + } + fmt.Fprintf(&b, "- [%s|%s] %s(截止 %s)%s\n", t.Status, t.Priority, t.Title, t.DueAt, t.Content) + } + b.WriteString("\n任务:分析这些待办的优先级排布是否合理,识别逾期风险,给出本周执行顺序建议。\n") + case "ticket": + tickets, _ := a.store.ListTickets("all", projectID) + b.WriteString("\n## 项目工单\n") + if len(tickets) == 0 { + b.WriteString("(暂无工单)\n") + } + for i, t := range tickets { + if i >= 30 { + break + } + fmt.Fprintf(&b, "- [%s|%s|%s] %s(%s ~ %s)%s\n", t.Type, t.Status, t.Priority, t.Title, t.StartAt, t.DueAt, t.Description) + } + b.WriteString("\n任务:从需求管理角度分析这些工单:排期是否合理、类型分布、阻塞风险,并给出处理顺序与拆解建议。\n") + } + return b.String() +} diff --git a/ai_scope.go b/ai_scope.go new file mode 100644 index 0000000..6a9d06b --- /dev/null +++ b/ai_scope.go @@ -0,0 +1,228 @@ +package main + +// ai_scope.go 提供页面级 AI 总结的上下文聚合: +// 启动台 / 运行日志 / 排期日历 / 配置 / 笔记 五个全局 scope, +// 结果统一存入 day_briefs(projectID=0),复用 RegenerateAISummary 流程。 + +import ( + "fmt" + "strings" + "time" +) + +// scopeSummaryKinds 是页面级总结的合法 kind 集合。 +var scopeSummaryKinds = map[string]bool{ + "launchpad": true, "logs": true, "calendar": true, "config": true, "notes": true, +} + +// writeScopeContext 按 scope 聚合当前应用数据,写入系统提示词。 +func (a *App) writeScopeContext(b *strings.Builder, scope string) { + switch scope { + case "launchpad": + a.writeLaunchpadContext(b) + case "logs": + a.writeLogsContext(b) + case "calendar": + a.writeCalendarContext(b) + case "config": + a.writeConfigContext(b) + case "notes": + a.writeNotesContext(b) + } +} + +func (a *App) writeLaunchpadContext(b *strings.Builder) { + entries, e := a.ListLaunchEntries() + b.WriteString("\n## 本机运行服务与已保存应用\n") + if e != nil || len(entries) == 0 { + b.WriteString("- 无\n") + return + } + n := 0 + for _, x := range entries { + if n >= 40 { + break + } + n++ + state := "已停止" + if x.Running { + state = "运行中" + } + saved := "扫描到" + if x.ID > 0 { + saved = "我的应用" + } + ports := x.Ports + if len(ports) == 0 && x.Port > 0 { + ports = []int{x.Port} + } + fmt.Fprintf(b, "- [%s|%s] %s(类型 %s,端口 %v,PID %d)CPU %.1f%%,内存 %.0fMB,IO %.0fKB/s\n", + saved, state, x.Name, x.Kind, ports, x.PID, x.CPU, x.MemMB, x.IOKBs) + } +} + +func (a *App) writeLogsContext(b *strings.Builder) { + logs, e := a.store.Logs("all") + b.WriteString("\n## 最近运行日志(新→旧)\n") + if e != nil || len(logs) == 0 { + b.WriteString("- 无\n") + return + } + counts := map[string]int{} + for _, l := range logs { + counts[l.Level]++ + } + fmt.Fprintf(b, "- 总量统计:错误 %d 条,警告 %d 条,信息 %d 条\n", counts["error"], counts["warning"], counts["info"]) + for i, l := range logs { + if i >= 200 { + break + } + detail := l.Detail + if r := []rune(detail); len(r) > 120 { + detail = string(r[:120]) + "…" + } + fmt.Fprintf(b, "- %s [%s|%s] %s %s\n", strings.Replace(l.CreatedAt, "T", " ", 1), l.Level, l.Category, l.Message, detail) + } +} + +func (a *App) writeCalendarContext(b *strings.Builder) { + now := time.Now() + today := now.Format("2006-01-02") + limit := now.AddDate(0, 0, 30).Format("2006-01-02") + fmt.Fprintf(b, "\n## 排期视角(今天 %s,展望 30 天)\n", today) + + todos, _ := a.store.ListTodos("all", 0) + tickets, _ := a.store.ListTickets("all", 0) + type item struct{ kind, title, status, priority, due, project string } + var overdue, upcoming []item + push := func(kind, title, status, priority, due, project string, doneLike bool) { + if doneLike { + return + } + d := due + if len(d) > 10 { + d = d[:10] + } + it := item{kind, title, status, priority, due, project} + switch { + case d != "" && d < today: + overdue = append(overdue, it) + case d != "" && d <= limit: + upcoming = append(upcoming, it) + } + } + for _, x := range todos { + push("待办", x.Title, x.Status, x.Priority, x.DueAt, x.ProjectName, x.Status == "done") + } + for _, x := range tickets { + push("工单", x.Title, x.Status, x.Priority, x.DueAt, x.ProjectName, x.Status == "resolved" || x.Status == "closed") + } + b.WriteString("\n## 已逾期\n") + if len(overdue) == 0 { + b.WriteString("- 无\n") + } + for i, x := range overdue { + if i >= 20 { + break + } + fmt.Fprintf(b, "- [%s|%s|%s] %s(截止 %s,项目 %s)\n", x.kind, x.status, x.priority, x.title, x.due, orDash(x.project)) + } + b.WriteString("\n## 未来 30 天到期\n") + if len(upcoming) == 0 { + b.WriteString("- 无\n") + } + for i, x := range upcoming { + if i >= 40 { + break + } + fmt.Fprintf(b, "- [%s|%s|%s] %s(截止 %s,项目 %s)\n", x.kind, x.status, x.priority, x.title, x.due, orDash(x.project)) + } +} + +func (a *App) writeConfigContext(b *strings.Builder) { + st, e := a.store.Settings() + if e != nil { + return + } + keyState := func(k string) string { + if strings.TrimSpace(k) != "" { + return "已配置" + } + return "未配置" + } + b.WriteString("\n## 当前应用配置\n") + fmt.Fprintf(b, "- 界面:主题 %s,语言 %s,加载动画 %s,玻璃透明度 %d%%\n", st.Theme, st.Locale, st.LoadingStyle, st.GlassOpacity) + fmt.Fprintf(b, "- 统计:Git 统计范围 %s,列表自动刷新 %v\n", st.GitScope, st.AutoRefresh) + fmt.Fprintf(b, "- 系统集成:关闭窗口最小化到托盘 %v\n", st.MinimizeToTray) + fmt.Fprintf(b, "- 自动更新统计:开启 %v,模式 %s,间隔 %d,触发时间 %s\n", st.AutoUpdateEnabled, st.AutoUpdateMode, st.AutoUpdateInterval, st.AutoUpdateTime) + fmt.Fprintf(b, "- AI:当前服务商 %s,星火 Key %s,DeepSeek Key %s,Key 云同步 %v\n", st.AIProvider, keyState(st.SparkKey), keyState(st.DeepSeekKey), st.SyncAPIKeys) + fmt.Fprintf(b, "- 图片存储模式:%s\n", st.ImageMode) + if rules, e := a.store.Rules(); e == nil { + custom := 0 + for _, r := range rules { + if !r.Builtin { + custom++ + } + } + fmt.Fprintf(b, "- 排除规则:共 %d 条(自定义 %d 条)\n", len(rules), custom) + } + if projects, e := a.store.ListProjects(0); e == nil { + fmt.Fprintf(b, "- 项目:共 %d 个\n", len(projects)) + } + if a.syncUserID() > 0 { + fmt.Fprintf(b, "- 云同步:已登录(%s)\n", a.store.Meta("sync_username")) + } else { + b.WriteString("- 云同步:未登录\n") + } +} + +func (a *App) writeNotesContext(b *strings.Builder) { + notes, e := a.store.ListNotes(200) + b.WriteString("\n## 全部笔记(新→旧)\n") + if e != nil || len(notes) == 0 { + b.WriteString("- 无\n") + return + } + for i, n := range notes { + if i >= 100 { + break + } + content := strings.TrimSpace(n.Content) + if r := []rune(content); len(r) > 400 { + content = string(r[:400]) + "…" + } + fmt.Fprintf(b, "\n### 笔记 %d(更新于 %s)\n%s\n", i+1, strings.Replace(n.UpdatedAt, "T", " ", 1), content) + } +} + +// scopeInstruction 页面级总结指令(与 summaryInstruction 同构,按 locale 切换)。 +func scopeInstruction(kind, locale string) (string, bool) { + if locale == "en" { + switch kind { + case "launchpad": + return "Based on the services above, summarize what is running on this machine (within 200 words): notable resource hogs, suspicious or duplicated services, and cleanup suggestions. Output a short Markdown list.", true + case "logs": + return "Based on the logs above, summarize recent application activity (within 200 words): recurring errors or warnings with likely causes, notable events, and suggested follow-ups. Output a short Markdown list.", true + case "calendar": + return "Based on the schedule above, summarize the coming month (within 200 words): overdue items to rescue first, busy periods, and scheduling advice. Only reference listed items. Output a short Markdown list.", true + case "config": + return "Based on the configuration above, review the app setup (within 200 words): risky or missing settings (e.g. AI keys, auto update, sync), and concrete tuning suggestions. Output a short Markdown list.", true + case "notes": + return "Based on the notes above, produce a digest (within 200 words): main themes, actionable items hidden in notes, and anything worth converting into todos. Only reference listed notes. Output a short Markdown list.", true + } + return "", false + } + switch kind { + case "launchpad": + return "基于以上服务清单,用不超过 250 字总结本机运行情况:资源占用突出的服务、可疑或重复的进程、值得关停或固化为常用应用的建议。直接输出 Markdown 列表,不要大标题。", true + case "logs": + return "基于以上日志,用不超过 250 字总结应用近期运行状况:反复出现的错误/警告及可能原因、值得关注的事件、建议的后续动作。直接输出 Markdown 列表,不要大标题。", true + case "calendar": + return "基于以上排期数据,用不超过 250 字总结未来一个月的日程:需要优先补救的逾期项、任务密集的时间段、排期调整建议。只能引用上面列出的条目,不要编造。直接输出 Markdown 列表,不要大标题。", true + case "config": + return "基于以上配置,用不超过 250 字点评当前应用设置:存在风险或缺失的配置(如 AI Key、自动统计、云同步),以及具体的调优建议。直接输出 Markdown 列表,不要大标题。", true + case "notes": + return "基于以上笔记,用不超过 250 字生成一份笔记摘要:主要主题、笔记里隐藏的待办事项、值得转成正式待办的内容。只能引用上面列出的笔记,不要编造。直接输出 Markdown 列表,不要大标题。", true + } + return "", false +} diff --git a/avatar.go b/avatar.go new file mode 100644 index 0000000..3507bd7 --- /dev/null +++ b/avatar.go @@ -0,0 +1,168 @@ +package main + +// avatar.go 实现用户头像的几种来源: +// base64(选本地图缩放后存 dataURL,可随账号同步)、url(OSS/图床直链)、path(本地路径,仅本机显示)。 +// 当管理员把全局文件存储方式配成 server 时,选图走 server 模式:缩放后上传 nl-pms-api, +// 以 url 模式保存返回的 http URL(显示与同步复用现有 url 逻辑)。 + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + _ "image/gif" + "image/jpeg" + "image/png" + "os" + "strings" + + _ "golang.org/x/image/webp" + + "github.com/wailsapp/wails/v3/pkg/application" + "golang.org/x/image/draw" +) + +const ( + avatarMaxFileBytes = 10 << 20 // 原始文件上限 10MB + avatarMaxEdge = 256 // 存储/显示的最长边 +) + +// PickAvatarImage 打开图片选择框。mode=auto(推荐入口)由管理员的全局文件存储 +// 配置决定走向:server 时缩放后上传 nl-pms-api 以 url 模式保存,否则存 base64。 +// 兼容旧 mode 取值:base64 存 dataURL、path 存本地路径、server 强制上传。 +// 返回的 Mode 是实际采用的存储模式,Preview 一律为可立即显示的 dataURL。 +// 用户取消选择时返回零值。 +func (a *App) PickAvatarImage(mode string) (AvatarPick, error) { + if e := a.ready(); e != nil { + return AvatarPick{}, e + } + p, e := application.Get().Dialog.OpenFile(). + SetTitle(a.localized("选择头像图片", "Choose avatar image")). + AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp"). + PromptForSingleSelection() + if e != nil || strings.TrimSpace(p) == "" { + return AvatarPick{}, e + } + data, mime, e := encodeAvatarFile(p) + if e != nil { + return AvatarPick{}, e + } + dataURL := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)) + if mode == "auto" { + // 上传时实时获取全局配置,管理员切换存储方式后立即生效。 + if a.currentFileStorage().Mode == "server" { + mode = "server" + } else { + mode = "base64" + } + } + switch mode { + case "path": + return AvatarPick{Mode: "path", Value: p, Preview: dataURL}, nil + case "server": + url, e := a.uploadImageToServer(data, mime, "avatar") + if e != nil { + return AvatarPick{}, e + } + // 落库为 url 模式:显示与同步复用现有 url 逻辑。 + return AvatarPick{Mode: "url", Value: url, Preview: dataURL}, nil + } + return AvatarPick{Mode: "base64", Value: dataURL, Preview: dataURL}, nil +} + +// ReadImageAsDataURL 把本地图片读成缩放后的 dataURL(path 模式启动时用来渲染头像)。 +func (a *App) ReadImageAsDataURL(path string) (string, error) { + if e := a.ready(); e != nil { + return "", e + } + return imageFileToDataURL(path) +} + +// ensureDefaultAvatar 给尚未设置头像的本机用户设默认头像:内嵌应用 logo 缩放后的 +// base64 dataURL。注册成功后调用;随后的自动登录同步会把它推成账号头像 +// (sync_settings 的 avatar + user_profiles 的 64px 缩略图),队友即可看到默认 logo 头像。 +// 本机已设置过头像则不覆盖;失败静默(默认头像不值得打断注册流程)。 +func (a *App) ensureDefaultAvatar() { + st, e := a.store.Settings() + if e != nil || strings.TrimSpace(st.AvatarValue) != "" { + return + } + data, mime, e := encodeImageEdge(appIcon, avatarMaxEdge) + if e != nil { + return + } + st.AvatarMode = "base64" + st.AvatarValue = fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)) + // SaveSettings 检测到头像变更会自动记录 avatar_updated_at,触发后续同步推送。 + if e := a.store.SaveSettings(st); e == nil { + a.store.Log("info", "同步", "已设置默认头像", "应用 logo") + } +} + +// encodeAvatarFile 读取图片、把最长边缩放到 avatarMaxEdge 以内,返回编码后的字节与 mime。 +// 源为 JPEG 时输出 JPEG(体积小),其余格式输出 PNG(保留透明度)。 +func encodeAvatarFile(path string) ([]byte, string, error) { + info, e := os.Stat(path) + if e != nil { + return nil, "", errors.New("FILE_NOT_FOUND") + } + if info.Size() > avatarMaxFileBytes { + return nil, "", errors.New("AVATAR_FILE_TOO_LARGE") + } + raw, e := os.ReadFile(path) + if e != nil { + return nil, "", errors.New("FILE_READ_FAILED") + } + src, format, e := image.Decode(bytes.NewReader(raw)) + if e != nil { + return nil, "", errors.New("AVATAR_DECODE_FAILED") + } + img := downscaleImage(src, avatarMaxEdge) + var buf bytes.Buffer + mime := "image/png" + if format == "jpeg" { + mime = "image/jpeg" + e = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}) + } else { + e = png.Encode(&buf, img) + } + if e != nil { + return nil, "", errors.New("AVATAR_DECODE_FAILED") + } + return buf.Bytes(), mime, nil +} + +// imageFileToDataURL 读取并缩放图片,编码为可直接显示的 dataURL。 +func imageFileToDataURL(path string) (string, error) { + data, mime, e := encodeAvatarFile(path) + if e != nil { + return "", e + } + return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil +} + +// downscaleImage 等比缩放到最长边 ≤ maxEdge;本身足够小则原样返回。 +func downscaleImage(src image.Image, maxEdge int) image.Image { + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxEdge && h <= maxEdge { + return src + } + if w >= h { + h = h * maxEdge / w + w = maxEdge + } else { + w = w * maxEdge / h + h = maxEdge + } + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil) + return dst +} diff --git a/avatar_test.go b/avatar_test.go new file mode 100644 index 0000000..d153629 --- /dev/null +++ b/avatar_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTestImage(t *testing.T, name string, w, h int, asJPEG bool) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x += 3 { + for y := 0; y < h; y += 3 { + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) + } + } + var buf bytes.Buffer + var e error + if asJPEG { + e = jpeg.Encode(&buf, img, nil) + } else { + e = png.Encode(&buf, img) + } + if e != nil { + t.Fatal(e) + } + p := filepath.Join(t.TempDir(), name) + if e := os.WriteFile(p, buf.Bytes(), 0o644); e != nil { + t.Fatal(e) + } + return p +} + +func decodeDataURL(t *testing.T, dataURL string) image.Image { + t.Helper() + i := strings.Index(dataURL, ";base64,") + if !strings.HasPrefix(dataURL, "data:image/") || i < 0 { + t.Fatalf("not a data URL: %.40s", dataURL) + } + raw, e := base64.StdEncoding.DecodeString(dataURL[i+8:]) + if e != nil { + t.Fatal(e) + } + img, _, e := image.Decode(bytes.NewReader(raw)) + if e != nil { + t.Fatal(e) + } + return img +} + +func TestImageFileToDataURLDownscalesLargeImages(t *testing.T) { + p := writeTestImage(t, "big.png", 600, 400, false) + dataURL, e := imageFileToDataURL(p) + if e != nil { + t.Fatal(e) + } + if !strings.HasPrefix(dataURL, "data:image/png;base64,") { + t.Fatalf("png source should stay png: %.40s", dataURL) + } + img := decodeDataURL(t, dataURL) + if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 170 { + t.Fatalf("expected 256x170, got %dx%d", b.Dx(), b.Dy()) + } +} + +func TestImageFileToDataURLKeepsSmallJPEG(t *testing.T) { + p := writeTestImage(t, "small.jpg", 120, 80, true) + dataURL, e := imageFileToDataURL(p) + if e != nil { + t.Fatal(e) + } + if !strings.HasPrefix(dataURL, "data:image/jpeg;base64,") { + t.Fatalf("jpeg source should stay jpeg: %.40s", dataURL) + } + img := decodeDataURL(t, dataURL) + if b := img.Bounds(); b.Dx() != 120 || b.Dy() != 80 { + t.Fatalf("small image should keep size, got %dx%d", b.Dx(), b.Dy()) + } +} + +func TestImageFileToDataURLErrors(t *testing.T) { + if _, e := imageFileToDataURL(filepath.Join(t.TempDir(), "missing.png")); e == nil || e.Error() != "FILE_NOT_FOUND" { + t.Fatalf("want FILE_NOT_FOUND, got %v", e) + } + bad := filepath.Join(t.TempDir(), "not-image.png") + if e := os.WriteFile(bad, []byte("hello world"), 0o644); e != nil { + t.Fatal(e) + } + if _, e := imageFileToDataURL(bad); e == nil || e.Error() != "AVATAR_DECODE_FAILED" { + t.Fatalf("want AVATAR_DECODE_FAILED, got %v", e) + } +} + +func TestEnsureDefaultAvatar(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "defavatar.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + a := &App{store: s} + a.ensureDefaultAvatar() + st, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if st.AvatarMode != "base64" || !strings.HasPrefix(st.AvatarValue, "data:image/") { + t.Fatalf("default avatar not set: mode=%q value=%.40s", st.AvatarMode, st.AvatarValue) + } + if s.Meta("avatar_updated_at") == "" { + t.Fatal("default avatar should bump avatar_updated_at so sync pushes it") + } + if img := decodeDataURL(t, st.AvatarValue); img.Bounds().Dx() > avatarMaxEdge || img.Bounds().Dy() > avatarMaxEdge { + t.Fatalf("default avatar should fit %dpx, got %v", avatarMaxEdge, img.Bounds()) + } + // 已设置过头像时不覆盖。 + st.AvatarMode, st.AvatarValue = "url", "https://cdn.example.com/me.png" + if e = s.SaveSettings(st); e != nil { + t.Fatal(e) + } + a.ensureDefaultAvatar() + got, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if got.AvatarMode != "url" || got.AvatarValue != "https://cdn.example.com/me.png" { + t.Fatalf("existing avatar should not be overwritten: mode=%q", got.AvatarMode) + } +} + +func TestSaveSettingsBumpsAvatarTimestampOnlyOnAvatarChange(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "avatar.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + base, e := s.Settings() + if e != nil { + t.Fatal(e) + } + base.Theme = "light" + if e = s.SaveSettings(base); e != nil { + t.Fatal(e) + } + if got := s.Meta("avatar_updated_at"); got != "" { + t.Fatalf("non-avatar save should not bump avatar timestamp, got %q", got) + } + base.AvatarMode, base.AvatarValue = "url", "https://cdn.example.com/a.png" + if e = s.SaveSettings(base); e != nil { + t.Fatal(e) + } + first := s.Meta("avatar_updated_at") + if first == "" { + t.Fatal("avatar change should bump timestamp") + } + got, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if got.AvatarMode != "url" || got.AvatarValue != "https://cdn.example.com/a.png" { + t.Fatalf("avatar not persisted: %+v", got) + } + // 非法模式会被清空存储。 + base.AvatarMode, base.AvatarValue = "weird", "x" + if e = s.SaveSettings(base); e != nil { + t.Fatal(e) + } + got, _ = s.Settings() + if got.AvatarMode != "" || got.AvatarValue != "" { + t.Fatalf("invalid mode should clear avatar, got %+v", got) + } +} diff --git a/build/Taskfile.yml b/build/Taskfile.yml new file mode 100644 index 0000000..0790f6b --- /dev/null +++ b/build/Taskfile.yml @@ -0,0 +1,398 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + # Universal/multi-arch builds invoke this concurrently from parallel deps; + # two `go mod tidy` processes racing on go.mod can corrupt it (#4637). + run: once + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies + run: once + cmds: + - task: install:frontend:deps:{{.PACKAGE_MANAGER}} + + install:frontend:deps:npm: + 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 + + install:frontend:deps:bun: + dir: frontend + sources: + - package.json + - bun.lock + - bun.lockb + generates: + - node_modules + preconditions: + - sh: bun --version + msg: "bun not found" + cmds: + - bun install + + install:frontend:deps:pnpm: + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "pnpm not found" + cmds: + - pnpm install + + install:frontend:deps:yarn: + dir: frontend + sources: + - package.json + - yarn.lock + status: + - test -d node_modules || test -f .pnp.cjs + preconditions: + - sh: yarn --version + msg: "yarn not found" + cmds: + - yarn install + + build:frontend: + label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}}) + summary: Build the frontend project + # darwin:build:universal runs its per-arch builds as parallel deps, each of + # which depends on this task. Without run:once the two executions race: + # one regenerates frontend/bindings (-clean deletes it first) while the + # other's bundler is reading it, failing intermittently with + # 'Could not resolve "./bindings/"' (#4637). + run: once + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + OBFUSCATED: + ref: .OBFUSCATED + cmds: + - task: frontend:run + vars: + SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}' + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' + + frontend:run: + summary: Run a frontend script with selected runner + cmds: + - task: frontend:run:{{.PACKAGE_MANAGER}} + vars: + SCRIPT: "{{.SCRIPT}}" + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:npm: + dir: frontend + cmds: + - npm run {{.SCRIPT}} -q + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:yarn: + dir: frontend + cmds: + - yarn {{.SCRIPT}} + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:pnpm: + dir: frontend + cmds: + - pnpm run {{.SCRIPT}} + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:bun: + dir: frontend + cmds: + - bun run {{.SCRIPT}} + vars: + SCRIPT: "{{.SCRIPT}}" + + 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 " "; 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: + summary: Generates bindings for the frontend + run: once + 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 -time-type=Date{{if eq .OBFUSCATED "true"}} -obfuscated{{end}} + + 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). + run: once + 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 + deps: + - task: install:frontend:deps + cmds: + - task: frontend:dev:{{.PACKAGE_MANAGER}} + + frontend:dev:npm: + dir: frontend + cmds: + - npm run dev -- --port {{.VITE_PORT}} --strictPort + + frontend:dev:yarn: + dir: frontend + cmds: + - yarn dev --port {{.VITE_PORT}} --strictPort + + frontend:dev:pnpm: + dir: frontend + cmds: + - pnpm dev --port {{.VITE_PORT}} --strictPort + + frontend:dev:bun: + dir: frontend + cmds: + - bun 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 a production server binary by default: -tags server,production, + -trimpath and a stripped binary, mirroring the desktop `build` task. + Server mode runs as a pure HTTP server without native GUI dependencies. + + Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2] + DEV=true development server (-tags server, no strip, inlining kept) + OBFUSCATED=true obfuscated build via garble (requires garble installed) + EXTRA_TAGS additional comma-separated build tags + deps: + - task: build:frontend + vars: + DEV: + ref: .DEV + BUILD_FLAGS: + ref: .BUILD_FLAGS + OBFUSCATED: + ref: .OBFUSCATED + preconditions: + - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}' + msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility." + cmds: + - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"' + vars: + BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' + + run:server: + summary: Builds and runs a development server (DEV=true) + deps: + - task: build:server + vars: + DEV: "true" + 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 production server binary. + Defaults to a pure-Go static binary on a distroless/static base. The + production frontend is built first so the embedded assets are current. + + Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...] + For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.: + task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12 + deps: + # Build the production frontend so frontend/dist (embedded by the Go build + # inside the image) is present and current in the Docker build context. + # Pass the server,production tags so binding generation analyses the same + # build the Docker image compiles, not the default-tag build. + - task: build:frontend + vars: + BUILD_FLAGS: "-tags server,production" + cmds: + - >- + docker build + --build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}} + --build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}} + --build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}} + -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/.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}}" diff --git a/build/config.yml b/build/config.yml new file mode 100644 index 0000000..fee82ec --- /dev/null +++ b/build/config.yml @@ -0,0 +1,79 @@ +# 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: "liqi" # The name of the company + productName: "年糕崽崽项目管理(PMS)" # The name of the application + productIdentifier: "com.liqi.codecount" # The unique product identifier + description: "本地代码统计与项目工作台" # The application description + copyright: "(c) 2026, liqi" # Copyright text + comments: "本地代码统计与项目工作台" # Comments + version: "2.0.0" # 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 + - "*_test.go" + 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 build DEV=true + type: blocking + - cmd: wails3 task common:dev:frontend + type: background + - 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 \ No newline at end of file diff --git a/build/darwin/Assets.car b/build/darwin/Assets.car new file mode 100644 index 0000000..32e5ad9 Binary files /dev/null and b/build/darwin/Assets.car differ diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml new file mode 100644 index 0000000..e3ee1ee --- /dev/null +++ b/build/darwin/Taskfile.yml @@ -0,0 +1,220 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # 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}}' + OBFUSCATED: '{{.OBFUSCATED}}' + GARBLE_ARGS: '{{.GARBLE_ARGS}}' + 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 + OBFUSCATED: + ref: .OBFUSCATED + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}' + msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility." + cmds: + - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"' + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{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=12.0" + CGO_LDFLAGS: "-mmacosx-version-min=12.0" + MACOSX_DEPLOYMENT_TARGET: "12.0" + + build:docker: + summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts) + internal: true + deps: + - task: common:build:frontend + vars: + OBFUSCATED: + ref: .OBFUSCATED + - 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" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}} + - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + platforms: [linux, darwin] + - 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 }}' + # Generate Docker volume mounts: Go module cache + go.mod replace directives + # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS) + DOCKER_MOUNTS: + sh: 'wails3 tool docker-mounts' + + 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 + + package:dmg: + summary: Packages the application into a styled `.dmg` + deps: + - task: package + cmds: + - task: create:dmg + + create:dmg: + summary: Creates a configurable `.dmg` from the `.app` bundle + platforms: [darwin] + cmds: + - >- + wails3 tool package --format dmg --name "{{.APP_NAME}}" --out "{{.BIN_DIR}}" + --background "{{.DMG_BACKGROUND}}" + --volume-icon "{{.DMG_VOLUME_ICON}}" + --file-icon "{{.DMG_FILE_ICON}}" + --window-width "{{.DMG_WINDOW_WIDTH}}" + --window-height "{{.DMG_WINDOW_HEIGHT}}" + {{if .DMG_FILES}}--files "{{.DMG_FILES}}"{{end}} + vars: + DMG_BACKGROUND: '{{.DMG_BACKGROUND | default "build/darwin/dmg-background.png"}}' + DMG_VOLUME_ICON: '{{.DMG_VOLUME_ICON | default "build/darwin/icons.icns"}}' + DMG_FILE_ICON: '{{.DMG_FILE_ICON | default "build/darwin/dmg-file-icon.icns"}}' + DMG_WINDOW_WIDTH: '{{.DMG_WINDOW_WIDTH | default "540"}}' + DMG_WINDOW_HEIGHT: '{{.DMG_WINDOW_HEIGHT | default "380"}}' + DMG_FILES: '{{.DMG_FILES | default ""}}' + + + 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. + Uses signing identity from `wails3 setup` (stored in ~/.config/wails/defaults.yaml). + Override with: task darwin:sign -- --identity "Developer ID Application: ..." + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" {{.CLI_ARGS}} + + sign:notarize: + summary: Signs and notarizes the application bundle + desc: | + Signs the .app bundle and submits it for notarization. + Uses signing identity and keychain profile from `wails3 setup`. + + First-time setup: + wails3 setup + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --notarize {{.CLI_ARGS}} diff --git a/build/darwin/dmg-background.png b/build/darwin/dmg-background.png new file mode 100644 index 0000000..ab099c8 Binary files /dev/null and b/build/darwin/dmg-background.png differ diff --git a/build/darwin/dmg-file-icon.icns b/build/darwin/dmg-file-icon.icns new file mode 100644 index 0000000..6817187 Binary files /dev/null and b/build/darwin/dmg-file-icon.icns differ diff --git a/build/darwin/dmg-file-icon.png b/build/darwin/dmg-file-icon.png new file mode 100644 index 0000000..cec0a40 Binary files /dev/null and b/build/darwin/dmg-file-icon.png differ diff --git a/build/darwin/icons.icns b/build/darwin/icons.icns new file mode 100644 index 0000000..81b100f Binary files /dev/null and b/build/darwin/icons.icns differ diff --git a/build/docker/Dockerfile.cross b/build/docker/Dockerfile.cross new file mode 100644 index 0000000..a9be99e --- /dev/null +++ b/build/docker/Dockerfile.cross @@ -0,0 +1,220 @@ +# 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.26-bookworm + +ARG TARGETARCH +ARG GARBLE_VERSION=v0.16.0 + +# Install base tools, GCC, and GTK/WebKit dev packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl xz-utils python3 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/* + +RUN go install mvdan.cc/garble@${GARBLE_VERSION} + +# Install Zig - automatically selects correct binary for host architecture. +# The SHA-256 is fetched from Zig's official download manifest and verified +# before extraction to protect against MITM or corrupted downloads. +ARG ZIG_VERSION=0.14.0 +RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + ZIG_FILE="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" && \ + curl -fSL "https://ziglang.org/download/${ZIG_VERSION}/${ZIG_FILE}" -o "/tmp/${ZIG_FILE}" && \ + EXPECTED=$(curl -fsSL "https://ziglang.org/download/index.json" | \ + python3 -c "import json,sys; d=json.load(sys.stdin); print(d[sys.argv[1]][sys.argv[2]+'-linux']['shasum'])" \ + "${ZIG_VERSION}" "${ZIG_ARCH}") && \ + echo "${EXPECTED} /tmp/${ZIG_FILE}" | sha256sum -c - && \ + tar -xJ -f "/tmp/${ZIG_FILE}" -C /opt && \ + rm "/tmp/${ZIG_FILE}" && \ + 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: " + 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 + +COMPILER="go build" +if [ "$OBFUSCATED" = "true" ]; then + COMPILER="garble ${GARBLE_ARGS} build" + TAGS="${TAGS},wails_obfuscated" +fi + +${COMPILER} -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"] diff --git a/build/docker/Dockerfile.server b/build/docker/Dockerfile.server new file mode 100644 index 0000000..7afbae3 --- /dev/null +++ b/build/docker/Dockerfile.server @@ -0,0 +1,67 @@ +# Wails Server Mode Dockerfile +# Multi-stage build for a minimal image. +# +# Defaults produce a pure-Go, fully static binary on a distroless/static base: +# the smallest, most secure image, and correct for the default (CGO-free) app. +# +# If your app needs CGO, override the build args, e.g.: +# docker build --build-arg CGO_ENABLED=1 \ +# --build-arg GO_IMAGE=golang:bookworm \ +# --build-arg RUNTIME_IMAGE=gcr.io/distroless/base-debian12 \ +# -f build/docker/Dockerfile.server . +# CGO needs a C toolchain in the builder and a libc in the runtime image; keep +# the builder and runtime libc compatible (a glibc builder -> a glibc runtime). +# The Taskfile passes these through: task build:docker CGO_ENABLED=1 GO_IMAGE=... RUNTIME_IMAGE=... + +ARG GO_IMAGE=golang:alpine +ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12 +ARG CGO_ENABLED=0 + +# Build stage +FROM ${GO_IMAGE} AS builder +ARG CGO_ENABLED + +WORKDIR /app + +# Install build dependencies: git always; a C compiler only when CGO is enabled. +# Supports both apk (alpine) and apt (debian/bookworm) base images. +RUN if command -v apk >/dev/null 2>&1; then \ + apk add --no-cache git $([ "$CGO_ENABLED" = "1" ] && echo gcc musl-dev); \ + else \ + apt-get update && apt-get install -y --no-install-recommends git $([ "$CGO_ENABLED" = "1" ] && echo gcc libc6-dev) && rm -rf /var/lib/apt/lists/*; \ + fi + +# 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 production server binary. +# - server,production tags: HTTP server mode with production code paths (no dev +# logger/asset middleware), matching the desktop production build. +# - CGO_ENABLED defaults to 0 for a fully static binary (distroless/static); +# override with --build-arg CGO_ENABLED=1 (see header) for CGO apps. +RUN CGO_ENABLED=${CGO_ENABLED} go build -tags server,production -trimpath -buildvcs=false -ldflags="-s -w" -o server . + +# Runtime stage - minimal image +FROM ${RUNTIME_IMAGE} + +# 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"] diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml new file mode 100644 index 0000000..496ee95 --- /dev/null +++ b/build/linux/Taskfile.yml @@ -0,0 +1,220 @@ +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}}' + OBFUSCATED: '{{.OBFUSCATED}}' + GARBLE_ARGS: '{{.GARBLE_ARGS}}' + 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) — cross-platform via wails3 tool + HAS_CC: + sh: 'wails3 tool has "gcc|clang"' + + 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 + OBFUSCATED: + ref: .OBFUSCATED + DEV: + ref: .DEV + - task: common:generate:icons + - task: generate:dotdesktop + preconditions: + - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}' + msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility." + cmds: + - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}' + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{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 + vars: + OBFUSCATED: + ref: .OBFUSCATED + - 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" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}} + - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + platforms: [linux, darwin] + - 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 }}' + # Generate Docker volume mounts: Go module cache + go.mod replace directives + # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS) + DOCKER_MOUNTS: + sh: 'wails3 tool docker-mounts' + + 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. + Set PGP_KEY in the vars section to override the key configured globally via + `wails3 setup` (~/.config/wails/defaults.yaml). + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:deb + cmds: + # PGP_KEY is optional: if unset, `wails3 tool sign` falls back to the key + # configured globally via `wails3 setup`. + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} {{if .SIGN_ROLE}}--role "{{.SIGN_ROLE}}"{{end}} + + sign:rpm: + summary: Signs the RPM package + desc: | + Signs the .rpm package with a PGP key. + Set PGP_KEY in the vars section to override the key configured globally via + `wails3 setup` (~/.config/wails/defaults.yaml). + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:rpm + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} + + sign:packages: + summary: Signs all Linux packages (DEB and RPM) + desc: | + Signs both .deb and .rpm packages with a PGP key. + Set PGP_KEY in the vars section to override the key configured globally via + `wails3 setup` (~/.config/wails/defaults.yaml). + Password is retrieved from system keychain (run: wails3 setup signing) + cmds: + - task: sign:deb + - task: sign:rpm diff --git a/build/linux/appimage/build.sh b/build/linux/appimage/build.sh new file mode 100644 index 0000000..85901c3 --- /dev/null +++ b/build/linux/appimage/build.sh @@ -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" + diff --git a/build/linux/desktop b/build/linux/desktop new file mode 100644 index 0000000..ab196cc --- /dev/null +++ b/build/linux/desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Name=My Product +Comment=My Product Description +# The Exec line includes %u to pass the URL to the application +Exec=/usr/local/bin/code-count.exe %u +Terminal=false +Type=Application +Icon=code-count.exe +Categories=Utility; +StartupWMClass=code-count.exe + + diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml new file mode 100644 index 0000000..2520d80 --- /dev/null +++ b/build/linux/nfpm/nfpm.yaml @@ -0,0 +1,80 @@ +# 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: "code-count.exe" +arch: ${GOARCH} +platform: "linux" +version: "0.1.0" +section: "default" +priority: "extra" +maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> +description: "My Product Description" +vendor: "liqi" +homepage: "https://wails.io" +license: "MIT" +release: "1" + +contents: + - src: "./bin/code-count.exe" + dst: "/usr/local/bin/code-count.exe" + - src: "./build/appicon.png" + dst: "/usr/share/icons/hicolor/128x128/apps/code-count.exe.png" + - src: "./build/linux/code-count.exe.desktop" + dst: "/usr/share/applications/code-count.exe.desktop" + +# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+) +depends: + - libgtk-4-1 + - libwebkitgtk-6.0-4 + +# Distribution-specific overrides for different package formats +overrides: + # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux + rpm: + depends: + - gtk4 + - webkitgtk6.0 + + # Arch Linux packages + archlinux: + depends: + - gtk4 + - webkitgtk-6.0 + +# 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" + +# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1), +# replace the depends/overrides above with these: +# +# depends: +# - libgtk-3-0 +# - libwebkit2gtk-4.1-0 +# overrides: +# rpm: +# depends: +# - gtk3 +# - webkit2gtk4.1 +# archlinux: +# depends: +# - gtk3 +# - webkit2gtk-4.1 +# +# replaces: +# - foobar +# provides: +# - bar +# recommends: +# - whatever +# suggests: +# - something-else +# conflicts: +# - not-foo +# - not-bar +# changelog: "changelog.yaml" diff --git a/build/linux/nfpm/scripts/postinstall.sh b/build/linux/nfpm/scripts/postinstall.sh new file mode 100644 index 0000000..4bbb815 --- /dev/null +++ b/build/linux/nfpm/scripts/postinstall.sh @@ -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 diff --git a/build/linux/nfpm/scripts/postremove.sh b/build/linux/nfpm/scripts/postremove.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/build/linux/nfpm/scripts/postremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/build/linux/nfpm/scripts/preinstall.sh b/build/linux/nfpm/scripts/preinstall.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/build/linux/nfpm/scripts/preinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/build/linux/nfpm/scripts/preremove.sh b/build/linux/nfpm/scripts/preremove.sh new file mode 100644 index 0000000..a9bf588 --- /dev/null +++ b/build/linux/nfpm/scripts/preremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml new file mode 100644 index 0000000..9cb4e35 --- /dev/null +++ b/build/windows/Taskfile.yml @@ -0,0 +1,195 @@ +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}}' + OBFUSCATED: '{{.OBFUSCATED}}' + GARBLE_ARGS: '{{.GARBLE_ARGS}}' + 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 + OBFUSCATED: + ref: .OBFUSCATED + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}' + msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility." + cmds: + - task: generate:syso + vars: + ARCH: '{{.ARCH}}' + - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.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 or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -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 + vars: + OBFUSCATED: + ref: .OBFUSCATED + - 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 + vars: + ARCH: '{{.ARCH}}' + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}} + - cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + platforms: [linux, darwin] + - rm -f *.syso + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + # Generate Docker volume mounts: Go module cache + go.mod replace directives + # Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS) + DOCKER_MOUNTS: + sh: 'wails3 tool docker-mounts' + + package: + summary: Packages the application + cmds: + - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}' + vars: + INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}' + vars: + FORMAT: '{{.FORMAT | default "nsis"}}' + INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}' + + 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 + preconditions: + - sh: '[ "{{.INSTALL_SCOPE}}" = "user" ] || [ "{{.INSTALL_SCOPE}}" = "machine" ]' + msg: "INSTALL_SCOPE must be 'user' or 'machine', got '{{.INSTALL_SCOPE}}'" + 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 {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi + {{else}} + makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-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}}' + INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}' + + 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. + Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate + configured globally via `wails3 setup` (~/.config/wails/defaults.yaml). + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: build + cmds: + # Certificate vars are optional: if unset, `wails3 tool sign` falls back to + # the certificate configured globally via `wails3 setup`. + - 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}} + + sign:installer: + summary: Signs the NSIS installer + desc: | + Creates and signs the NSIS installer. + Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate + configured globally via `wails3 setup` (~/.config/wails/defaults.yaml). + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:nsis:installer + vars: + INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}' + vars: + INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}' + 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}} diff --git a/build/windows/msix/app_manifest.xml b/build/windows/msix/app_manifest.xml new file mode 100644 index 0000000..69ac5a7 --- /dev/null +++ b/build/windows/msix/app_manifest.xml @@ -0,0 +1,55 @@ + + + + + + + My Product + liqi + My Product Description + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/windows/msix/template.xml b/build/windows/msix/template.xml new file mode 100644 index 0000000..2eb2d3f --- /dev/null +++ b/build/windows/msix/template.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + false + My Product + liqi + My Product Description + Assets\AppIcon.png + + + + + + + diff --git a/build/windows/nsis/project.nsi b/build/windows/nsis/project.nsi new file mode 100644 index 0000000..68d5176 --- /dev/null +++ b/build/windows/nsis/project.nsi @@ -0,0 +1,119 @@ +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 "Code Count" +## !define INFO_COMPANYNAME "My Company" # Default "liqi" +## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0" +## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© now, 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 +## !define WAILS_INSTALL_SCOPE "user" # Default "machine" - set to "user" for per-user install ($LOCALAPPDATA) without UAC prompt +#### +## 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. +!if "${WAILS_INSTALL_SCOPE}" == "user" + InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" +!else + InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" +!endif +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 diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh new file mode 100644 index 0000000..cd7c4c3 --- /dev/null +++ b/build/windows/nsis/wails_tools.nsh @@ -0,0 +1,261 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "年糕崽崽项目管理(PMS)" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "liqi" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "My Product" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "0.1.0" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "© now, 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 WAILS_INSTALL_SCOPE + !define WAILS_INSTALL_SCOPE "machine" +!endif + +!ifndef REQUEST_EXECUTION_LEVEL + !if "${WAILS_INSTALL_SCOPE}" == "user" + !define REQUEST_EXECUTION_LEVEL "user" + !else + !define REQUEST_EXECUTION_LEVEL "admin" + !endif +!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 + !if "${WAILS_INSTALL_SCOPE}" == "user" + WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0" + !else + 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" + !endif +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + !if "${WAILS_INSTALL_SCOPE}" == "user" + DeleteRegKey HKCU "${UNINST_KEY}" + !else + DeleteRegKey HKLM "${UNINST_KEY}" + !endif +!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 \ No newline at end of file diff --git a/clone.go b/clone.go new file mode 100644 index 0000000..cfb8edc --- /dev/null +++ b/clone.go @@ -0,0 +1,150 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "view/platform" +) + +// ---------------- 从 Git 克隆并添加项目 ---------------- + +// repoNameFromURL 从 Git 地址推导目录名(支持 https / ssh / 本地路径形式)。 +func repoNameFromURL(u string) string { + s := strings.TrimSpace(u) + s = strings.TrimRight(s, "/\\") + s = strings.TrimSuffix(s, ".git") + if i := strings.LastIndexAny(s, "/:\\"); i >= 0 { + s = s[i+1:] + } + s = regexp.MustCompile(`[<>:"/\\|?*\s]+`).ReplaceAllString(s, "-") + return strings.Trim(s, "-.") +} + +var cloneProgressRe = regexp.MustCompile(`(Counting objects|Compressing objects|Receiving objects|Resolving deltas):\s+(\d+)%`) + +// cloneProgress 把 git 各阶段百分比映射到 0-98 的总体进度。 +func cloneProgress(line string) (int, bool) { + m := cloneProgressRe.FindStringSubmatch(line) + if m == nil { + return 0, false + } + pct, _ := strconv.Atoi(m[2]) + switch m[1] { + case "Counting objects": + return 2 + pct*3/100, true + case "Compressing objects": + return 5 + pct*5/100, true + case "Receiving objects": + return 10 + pct*75/100, true + default: // Resolving deltas + return 85 + pct*13/100, true + } +} + +// scanCRLines 以 \r 或 \n 切行:git 进度用回车原地刷新,普通行以换行结束。 +func scanCRLines(data []byte, atEOF bool) (int, []byte, error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexAny(data, "\r\n"); i >= 0 { + return i + 1, data[:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +} + +// CloneProject 把远程仓库克隆到指定父目录后直接加入项目列表。 +// 进度通过 analysis:progress 事件复用任务条 / 全屏加载展示。 +func (a *App) CloneProject(in CloneInput) (Project, error) { + if e := a.ready(); e != nil { + return Project{}, e + } + url := strings.TrimSpace(in.URL) + if url == "" { + return Project{}, errors.New("URL_REQUIRED") + } + parent := strings.TrimSpace(in.ParentDir) + if st, e := os.Stat(parent); e != nil || !st.IsDir() { + return Project{}, errors.New("DIR_NOT_FOUND") + } + name := strings.TrimSpace(in.Name) + if name == "" { + name = repoNameFromURL(url) + } + if name == "" { + return Project{}, errors.New("NAME_REQUIRED") + } + target := filepath.Join(parent, name) + if ents, e := os.ReadDir(target); e == nil && len(ents) > 0 { + return Project{}, errors.New("TARGET_NOT_EMPTY") + } + + taskID := "clone-" + name + emitP := func(stage string, progress int, key string) { + a.emit("analysis:progress", TaskEvent{TaskID: taskID, Stage: stage, Progress: progress, MessageKey: key, Params: map[string]any{"project": name}}) + } + emitP("cloning", 1, "task.cloning") + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "clone", "--progress", url, target) + // 禁止 git 弹出交互式凭据询问,私有仓库直接快速失败 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never") + platform.ConfigureHidden(cmd) + stderr, e := cmd.StderrPipe() + if e != nil { + emitP("error", 100, "task.cloneFailed") + return Project{}, e + } + if e := cmd.Start(); e != nil { + emitP("error", 100, "task.cloneFailed") + a.store.Log("error", "项目", "Git 克隆失败", url+" | "+e.Error()) + return Project{}, e + } + sc := bufio.NewScanner(stderr) + sc.Split(scanCRLines) + tail := []string{} + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + if len(tail) >= 6 { + tail = tail[1:] + } + tail = append(tail, line) + if p, ok := cloneProgress(line); ok { + emitP("cloning", p, "task.cloning") + } + } + if e := cmd.Wait(); e != nil { + _ = os.RemoveAll(target) // 清掉半成品目录(克隆前已确认为空/不存在) + msg := strings.Join(tail, "\n") + a.store.Log("error", "项目", "Git 克隆失败", url+" | "+msg) + emitP("error", 100, "task.cloneFailed") + if msg != "" { + return Project{}, errors.New(msg) + } + return Project{}, e + } + p, e := a.SaveProject(0, ProjectInput{Name: name, Path: target, Description: in.Description, GroupID: in.GroupID}) + if e != nil { + emitP("error", 100, "task.cloneFailed") + return p, e + } + a.store.Log("info", "项目", "Git 克隆成功", url+" -> "+target) + emitP("completed", 100, "task.cloneDone") + return p, nil +} diff --git a/clone_test.go b/clone_test.go new file mode 100644 index 0000000..53a2a39 --- /dev/null +++ b/clone_test.go @@ -0,0 +1,45 @@ +package main + +import "testing" + +func TestRepoNameFromURL(t *testing.T) { + cases := map[string]string{ + "https://github.com/user/my-repo.git": "my-repo", + "https://github.com/user/my-repo": "my-repo", + "https://github.com/user/my-repo/": "my-repo", + "git@github.com:user/awesome.git": "awesome", + "ssh://git@host:2222/team/svc.git": "svc", + "https://gitee.com/a/b c.git": "b-c", + " https://github.com/x/trim.git ": "trim", + "https://github.com/user/dots...git": "dots", // 残余尾点会被修剪(Windows 目录名尾部点非法) + "https://github.com/user/UPPER.git": "UPPER", + `D:\code\my-repo`: "my-repo", // Windows 本地路径克隆 + `D:\code\my-repo\`: "my-repo", + } + for in, want := range cases { + if got := repoNameFromURL(in); got != want { + t.Errorf("repoNameFromURL(%q)=%q want %q", in, got, want) + } + } +} + +func TestCloneProgress(t *testing.T) { + cases := []struct { + line string + want int + ok bool + }{ + {"Receiving objects: 0% (1/100)", 10, true}, + {"Receiving objects: 100% (100/100), done.", 85, true}, + {"Resolving deltas: 100% (50/50), done.", 98, true}, + {"Counting objects: 50% (5/10)", 3, true}, + {"Compressing objects: 100% (9/9), done.", 10, true}, + {"Cloning into 'demo'...", 0, false}, + } + for _, c := range cases { + got, ok := cloneProgress(c.line) + if ok != c.ok || got != c.want { + t.Errorf("cloneProgress(%q)=(%d,%v) want (%d,%v)", c.line, got, ok, c.want, c.ok) + } + } +} diff --git a/cloudbind.go b/cloudbind.go new file mode 100644 index 0000000..75478e0 --- /dev/null +++ b/cloudbind.go @@ -0,0 +1,197 @@ +package main + +// cloudbind.go 实现“项目身份与机器路径分离”的同步支撑: +// - machineID:本机稳定标识(哈希后缓存),云端行名 paths: +// - machinePathsDoc / applyMachinePaths:只推拉本机的 {项目名:路径} 行 +// - 云端有身份但本机没路径的项目进入待绑定清单,由 Dashboard 引导用户选目录绑定。 + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "os" + "strings" +) + +// machineID 返回本机稳定标识(原始 ID 哈希后取 16 个十六进制字符),缓存于 meta。 +func (a *App) machineID() string { + if a.store == nil { + return "unknown" + } + if v := a.store.Meta("machine_id"); v != "" { + return v + } + raw := rawMachineID() + if raw == "" { + raw, _ = os.Hostname() + } + if raw == "" { + raw = "unknown-machine" + } + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(raw)))) + id := hex.EncodeToString(sum[:8]) + _ = a.store.SetMeta("machine_id", id) + return id +} + +// machinePathsDoc 生成本机路径文档 {项目名: 本机路径}(map 序列化按键排序,内容稳定可比较)。 +func (a *App) machinePathsDoc() (string, error) { + rows, e := a.store.db.Query(`SELECT name,path FROM projects ORDER BY name`) + if e != nil { + return "", e + } + defer rows.Close() + m := map[string]string{} + for rows.Next() { + var name, path string + if e := rows.Scan(&name, &path); e != nil { + return "", e + } + if name != "" && path != "" { + m[name] = path + } + } + b, e := json.Marshal(m) + return string(b), e +} + +// applyMachinePaths 套用本机路径文档(换库/重装同机场景): +// 本地没有该名项目且路径非空 → 直接入库;已有同名项目但路径不同 → 以远端为准更新。 +func (a *App) applyMachinePaths(doc string) error { + var m map[string]string + if json.Unmarshal([]byte(doc), &m) != nil { + return nil // 远端数据异常时忽略,不阻断同步 + } + for name, path := range m { + name, path = strings.TrimSpace(name), strings.TrimSpace(path) + if name == "" || path == "" { + continue + } + var pid int64 + var cur string + e := a.store.db.QueryRow(`SELECT id,path FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid, &cur) + switch { + case e == sql.ErrNoRows: + now := nowRFC() + // path 全局唯一:同路径已有别名项目时不强插(IGNORE)。 + if _, e := a.store.db.Exec(`INSERT OR IGNORE INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,'',1,?,?)`, + name, path, now, now); e != nil { + return e + } + case e != nil: + return e + case cur != path: + if _, e := a.store.db.Exec(`UPDATE projects SET path=?,updated_at=? WHERE id=? + AND NOT EXISTS(SELECT 1 FROM projects p2 WHERE p2.path=? AND p2.id!=?)`, + path, nowRFC(), pid, path, pid); e != nil { + return e + } + } + } + return nil +} + +// rebuildCloudPending 以最近一次云端身份文档为基准,重算仍未落地本机的项目清单(幂等)。 +func (a *App) rebuildCloudPending(entries []projectDocEntry) { + pending := []projectDocEntry{} + for _, it := range entries { + name := strings.TrimSpace(it.Name) + if name == "" { + continue + } + var pid int64 + if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid) == sql.ErrNoRows { + it.Path = "" // 待绑定清单不携带其它机器的路径 + pending = append(pending, it) + } + } + b, _ := json.Marshal(pending) + _ = a.store.SetMeta("cloud_projects_pending", string(b)) +} + +// ListCloudPendingProjects 返回云端存在但本机尚未绑定路径的项目。 +func (a *App) ListCloudPendingProjects() ([]projectDocEntry, error) { + if e := a.ready(); e != nil { + return nil, e + } + out := []projectDocEntry{} + raw := a.store.Meta("cloud_projects_pending") + if raw == "" { + return out, nil + } + var list []projectDocEntry + if json.Unmarshal([]byte(raw), &list) != nil { + return out, nil + } + for _, it := range list { + // 本地可能在同步间隙手动建了同名项目,再过滤一遍 + var pid int64 + if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, it.Name).Scan(&pid) == sql.ErrNoRows { + out = append(out, it) + } + } + return out, nil +} + +// BindCloudProject 把云端项目绑定到本机目录:带身份信息入库、移出待绑定清单,并触发一轮推送。 +func (a *App) BindCloudProject(name, path string) (Project, error) { + if e := a.ready(); e != nil { + return Project{}, e + } + name, path = strings.TrimSpace(name), strings.TrimSpace(path) + if name == "" { + return Project{}, errors.New("NAME_REQUIRED") + } + if fi, e := os.Stat(path); e != nil || !fi.IsDir() { + return Project{}, errors.New("DIR_NOT_FOUND") + } + entry := projectDocEntry{Name: name} + if raw := a.store.Meta("cloud_projects_pending"); raw != "" { + var list []projectDocEntry + if json.Unmarshal([]byte(raw), &list) == nil { + rest := make([]projectDocEntry, 0, len(list)) + for _, it := range list { + if strings.TrimSpace(it.Name) == name { + entry = it + continue + } + rest = append(rest, it) + } + b, _ := json.Marshal(rest) + _ = a.store.SetMeta("cloud_projects_pending", string(b)) + } + } + gid, e := a.ensureGroupID(entry.Group) + if e != nil { + return Project{}, e + } + var pid int64 + now := nowRFC() + switch e := a.store.db.QueryRow(`SELECT id FROM projects WHERE path=?`, path).Scan(&pid); { + case e == sql.ErrNoRows: + r, e2 := a.store.db.Exec(`INSERT INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`, + name, path, entry.Description, gid, now, now) + if e2 != nil { + return Project{}, e2 + } + pid, _ = r.LastInsertId() + case e != nil: + return Project{}, e + default: + // 该目录已是本地项目:改名并入云端身份,避免出现两个同路径项目。 + if _, e := a.store.db.Exec(`UPDATE projects SET name=?,description=?,group_id=?,updated_at=? WHERE id=?`, + name, entry.Description, gid, now, pid); e != nil { + return Project{}, e + } + } + if entry.Favorite { + _, _ = a.store.db.Exec(`INSERT OR IGNORE INTO favorites(project_id,created_at) VALUES(?,?)`, pid, now) + } + a.store.Log("info", "同步", "云端项目已绑定本机目录", name+" → "+path) + if a.syncUserID() > 0 { + go a.syncOnce(true) + } + return a.store.GetProject(pid) +} diff --git a/festival.go b/festival.go new file mode 100644 index 0000000..bc9466f --- /dev/null +++ b/festival.go @@ -0,0 +1,215 @@ +package main + +// festival.go 日历节日/节气自定义背景图。 +// 管理员(云端账号 id=1)上传后写入本地 festival_images 表并随同步分发: +// 云端存放在 sync_settings 的 fest_img:<节日名> 行(统一挂在 id=1 名下), +// 管理员推送本地修改,所有账号拉取,实现"管理员配置、全员可见"。 +// +// 行 value 为 JSON {"mode":"photo|art","image":"data:..."}: +// mode 决定日历格用照片还是动态插画(art 模式仍保留图片,切回无需重传); +// 兼容旧格式(value 直接是 dataURL,视为 photo 模式);空值为删除墓碑。 + +import ( + "encoding/base64" + "encoding/json" + "errors" + "os" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +const ( + festivalAdminID = 1 + festivalImgEdge = 760 // 铺在日历格背景不需要原图精度,压小以控制同步体积 +) + +func parseFestivalValue(v string) (FestivalImage, bool) { + if v == "" { + return FestivalImage{}, false + } + if strings.HasPrefix(v, "data:") { + return FestivalImage{Mode: "photo", Image: v}, true // 旧格式:裸 dataURL + } + var f FestivalImage + if json.Unmarshal([]byte(v), &f) != nil || f.Image == "" { + return FestivalImage{}, false + } + if f.Mode != "art" { + f.Mode = "photo" + } + return f, true +} + +// ListFestivalImages 返回节日名 → 背景配置的映射(本地缓存,含拉取到的云端配置)。 +func (a *App) ListFestivalImages() (map[string]FestivalImage, error) { + if e := a.ready(); e != nil { + return nil, e + } + rows, e := a.store.db.Query(`SELECT fest_key,value FROM festival_images WHERE value!=''`) + if e != nil { + return nil, e + } + defer rows.Close() + m := map[string]FestivalImage{} + for rows.Next() { + var k, v string + if e := rows.Scan(&k, &v); e != nil { + return nil, e + } + if f, ok := parseFestivalValue(v); ok { + m[k] = f + } + } + return m, rows.Err() +} + +func (a *App) festivalAdminGate() error { + if a.syncUserID() != festivalAdminID { + return errors.New("FESTIVAL_IMG_ADMIN_ONLY") + } + return nil +} + +// PickFestivalImage 管理员为某个节日选择本地图片;处理保存后返回背景配置,取消返回空 image。 +func (a *App) PickFestivalImage(key string) (FestivalImage, error) { + if e := a.ready(); e != nil { + return FestivalImage{}, e + } + if e := a.festivalAdminGate(); e != nil { + return FestivalImage{}, e + } + p, e := application.Get().Dialog.OpenFile(). + SetTitle(a.localized("选择节日背景图", "Choose festival background")). + AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp"). + PromptForSingleSelection() + if e != nil || strings.TrimSpace(p) == "" { + return FestivalImage{}, e + } + info, e := os.Stat(p) + if e != nil { + return FestivalImage{}, errors.New("FILE_NOT_FOUND") + } + if info.Size() > contentImgMaxBytes { + return FestivalImage{}, errors.New("IMAGE_FILE_TOO_LARGE") + } + raw, e := os.ReadFile(p) + if e != nil { + return FestivalImage{}, errors.New("FILE_READ_FAILED") + } + return a.saveFestivalImage(key, raw) +} + +// SetFestivalImageData 管理员以 dataURL 形式设置节日图(自动化/粘贴场景)。 +func (a *App) SetFestivalImageData(key, dataURL string) (FestivalImage, error) { + if e := a.ready(); e != nil { + return FestivalImage{}, e + } + if e := a.festivalAdminGate(); e != nil { + return FestivalImage{}, e + } + raw, e := dataURLBytes(dataURL) + if e != nil { + return FestivalImage{}, e + } + return a.saveFestivalImage(key, raw) +} + +// SetFestivalImageMode 切换某节日的显示模式:photo=自定义照片,art=动态插画。 +// 仅在已有图片的行上生效(无图时本就显示动态插画)。 +func (a *App) SetFestivalImageMode(key, mode string) error { + if e := a.ready(); e != nil { + return e + } + if e := a.festivalAdminGate(); e != nil { + return e + } + key = strings.TrimSpace(key) + if key == "" || (mode != "photo" && mode != "art") { + return errors.New("FESTIVAL_IMG_BAD_KEY") + } + var v string + if e := a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key=?`, key).Scan(&v); e != nil { + return errors.New("FESTIVAL_IMG_NOT_FOUND") + } + f, ok := parseFestivalValue(v) + if !ok { + return errors.New("FESTIVAL_IMG_NOT_FOUND") + } + if f.Mode == mode { + return nil + } + f.Mode = mode + payload, e := json.Marshal(f) + if e != nil { + return e + } + if _, e := a.store.db.Exec(`UPDATE festival_images SET value=?,updated_at=?,dirty=1 WHERE fest_key=?`, + string(payload), a.festNextStamp(key), key); e != nil { + return e + } + a.store.Log("info", "日历", "节日背景模式已切换", key+" → "+mode) + go a.syncOnce(true) + return nil +} + +// RemoveFestivalImage 移除自定义图(保留行并置空,作为删除标记同步给其他设备)。 +func (a *App) RemoveFestivalImage(key string) error { + if e := a.ready(); e != nil { + return e + } + if e := a.festivalAdminGate(); e != nil { + return e + } + key = strings.TrimSpace(key) + if key == "" { + return errors.New("FESTIVAL_IMG_BAD_KEY") + } + if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,'',?,1) + ON CONFLICT(fest_key) DO UPDATE SET value='',updated_at=excluded.updated_at,dirty=1`, key, a.festNextStamp(key)); e != nil { + return e + } + a.store.Log("info", "日历", "节日图片已移除", key) + go a.syncOnce(true) + return nil +} + +// festNextStamp 生成该节日行的下一个时间戳,保证严格大于现有值: +// nowRFC 精度只有秒,同一秒内先设图再移除会得到相同时间戳, +// 云端 LWW 的严格大于比较会拒绝第二次写入,本地却已清 dirty,造成永久分叉。 +func (a *App) festNextStamp(key string) string { + now := nowRFC() + var prev string + _ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key=?`, key).Scan(&prev) + if prev < now { + return now + } + if t, e := time.Parse(time.RFC3339, prev); e == nil { + return t.Add(time.Second).UTC().Format(time.RFC3339) + } + return now +} + +func (a *App) saveFestivalImage(key string, raw []byte) (FestivalImage, error) { + key = strings.TrimSpace(key) + if key == "" || len(key) > 40 { + return FestivalImage{}, errors.New("FESTIVAL_IMG_BAD_KEY") + } + data, mime, e := encodeImageEdge(raw, festivalImgEdge) + if e != nil { + return FestivalImage{}, e + } + f := FestivalImage{Mode: "photo", Image: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)} + payload, e := json.Marshal(f) + if e != nil { + return FestivalImage{}, e + } + if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,?,?,1) + ON CONFLICT(fest_key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at,dirty=1`, key, string(payload), a.festNextStamp(key)); e != nil { + return FestivalImage{}, e + } + a.store.Log("info", "日历", "节日图片已更新", key) + go a.syncOnce(true) + return f, nil +} diff --git a/festival_test.go b/festival_test.go new file mode 100644 index 0000000..4abc76c --- /dev/null +++ b/festival_test.go @@ -0,0 +1,219 @@ +package main + +// festival_test.go covers festival background image bindings: +// admin gate (cloud user id must be 1), save/list/remove round trip, +// display mode switching, legacy payload parsing, dirty flags for sync. + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/png" + "strings" + "testing" +) + +// festTestApp returns an app whose sync target points at an unreachable port, +// so the background sync kick started by save/remove fails fast and touches nothing. +func festTestApp(t *testing.T) *App { + a := newSyncTestApp(t) + _ = a.store.SetMeta("sync_host", "127.0.0.1") + _ = a.store.SetMeta("sync_port", "1") + _ = a.store.SetMeta("sync_user", "x") + _ = a.store.SetMeta("sync_database", "nope") + return a +} + +func festPngDataURL(t *testing.T, w, h int, c color.Color) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if e := png.Encode(&buf, img); e != nil { + t.Fatal(e) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func TestFestivalImageAdminGate(t *testing.T) { + a := festTestApp(t) + data := festPngDataURL(t, 8, 8, color.RGBA{200, 40, 40, 255}) + + // not logged in + if _, e := a.SetFestivalImageData("春节", data); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" { + t.Fatalf("expected admin-only error when logged out, got %v", e) + } + // logged in as a regular user + _ = a.store.SetMeta("sync_user_id", "2") + if _, e := a.SetFestivalImageData("春节", data); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" { + t.Fatalf("expected admin-only error for user 2, got %v", e) + } + if e := a.RemoveFestivalImage("春节"); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" { + t.Fatalf("expected admin-only error on remove, got %v", e) + } + if e := a.SetFestivalImageMode("春节", "art"); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" { + t.Fatalf("expected admin-only error on mode switch, got %v", e) + } +} + +func TestFestivalImageSaveListRemove(t *testing.T) { + a := festTestApp(t) + _ = a.store.SetMeta("sync_user_id", "1") + + f, e := a.SetFestivalImageData("春节", festPngDataURL(t, 900, 300, color.RGBA{220, 30, 30, 255})) + if e != nil { + t.Fatal(e) + } + // uploading implies photo mode; opaque input re-encodes as jpeg below the edge cap + if f.Mode != "photo" || !strings.HasPrefix(f.Image, "data:image/jpeg;base64,") { + t.Fatalf("unexpected payload: mode=%q image=%.40s", f.Mode, f.Image) + } + if len(f.Image) > 200_000 { + t.Fatalf("encoded image unexpectedly large: %d", len(f.Image)) + } + + m, e := a.ListFestivalImages() + if e != nil { + t.Fatal(e) + } + if m["春节"].Image != f.Image || m["春节"].Mode != "photo" { + t.Fatalf("list mismatch: got %d entries", len(m)) + } + + var dirty int + if e := a.store.db.QueryRow(`SELECT dirty FROM festival_images WHERE fest_key='春节'`).Scan(&dirty); e != nil || dirty != 1 { + t.Fatalf("expected dirty=1 after save, got %d (%v)", dirty, e) + } + + // second save overwrites the value + f2, e := a.SetFestivalImageData("春节", festPngDataURL(t, 100, 100, color.RGBA{20, 90, 220, 255})) + if e != nil { + t.Fatal(e) + } + if f2.Image == f.Image { + t.Fatal("expected a different data URL after overwrite") + } + + if e := a.RemoveFestivalImage("春节"); e != nil { + t.Fatal(e) + } + m, _ = a.ListFestivalImages() + if len(m) != 0 { + t.Fatalf("expected empty list after remove, got %d", len(m)) + } + // removal keeps the row as an empty-value tombstone marked dirty + var v string + if e := a.store.db.QueryRow(`SELECT value,dirty FROM festival_images WHERE fest_key='春节'`).Scan(&v, &dirty); e != nil || v != "" || dirty != 1 { + t.Fatalf("expected empty dirty tombstone, got value=%q dirty=%d (%v)", v, dirty, e) + } +} + +func TestFestivalImageModeSwitch(t *testing.T) { + a := festTestApp(t) + _ = a.store.SetMeta("sync_user_id", "1") + if _, e := a.SetFestivalImageData("中秋", festPngDataURL(t, 50, 50, color.RGBA{230, 190, 90, 255})); e != nil { + t.Fatal(e) + } + var at1 string + _ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='中秋'`).Scan(&at1) + + if e := a.SetFestivalImageMode("中秋", "art"); e != nil { + t.Fatal(e) + } + m, _ := a.ListFestivalImages() + if m["中秋"].Mode != "art" || m["中秋"].Image == "" { + t.Fatalf("mode switch must keep image, got %+v", m["中秋"]) + } + // stamp must strictly increase so the switch wins cloud-side LWW + var at2 string + var dirty int + _ = a.store.db.QueryRow(`SELECT updated_at,dirty FROM festival_images WHERE fest_key='中秋'`).Scan(&at2, &dirty) + if at2 <= at1 || dirty != 1 { + t.Fatalf("expected newer dirty stamp, at1=%q at2=%q dirty=%d", at1, at2, dirty) + } + + // switching to the same mode is a no-op; unknown mode / missing row are rejected + if e := a.SetFestivalImageMode("中秋", "art"); e != nil { + t.Fatal(e) + } + if e := a.SetFestivalImageMode("中秋", "gif"); e == nil { + t.Fatal("expected error for bad mode") + } + if e := a.SetFestivalImageMode("不存在", "art"); e == nil || e.Error() != "FESTIVAL_IMG_NOT_FOUND" { + t.Fatalf("expected FESTIVAL_IMG_NOT_FOUND, got %v", e) + } + + if e := a.SetFestivalImageMode("中秋", "photo"); e != nil { + t.Fatal(e) + } + m, _ = a.ListFestivalImages() + if m["中秋"].Mode != "photo" { + t.Fatalf("expected photo mode, got %+v", m["中秋"]) + } +} + +// Rows written by the previous version store the raw data URL; they must be +// read back as photo mode so existing cloud data keeps working. +func TestFestivalImageLegacyPayload(t *testing.T) { + a := festTestApp(t) + _ = a.store.SetMeta("sync_user_id", "1") + raw := festPngDataURL(t, 4, 4, color.RGBA{1, 2, 3, 255}) + if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES('元旦',?,?,0)`, raw, nowRFC()); e != nil { + t.Fatal(e) + } + m, e := a.ListFestivalImages() + if e != nil { + t.Fatal(e) + } + if m["元旦"].Mode != "photo" || m["元旦"].Image != raw { + t.Fatalf("legacy payload mismatch: %+v", m["元旦"]) + } + // mode switch upgrades the row to the JSON payload + if e := a.SetFestivalImageMode("元旦", "art"); e != nil { + t.Fatal(e) + } + var v string + _ = a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key='元旦'`).Scan(&v) + if !strings.HasPrefix(v, "{") || !strings.Contains(v, `"mode":"art"`) { + t.Fatalf("row not upgraded to JSON: %.60s", v) + } +} + +// Save and remove within the same second must still produce strictly increasing +// timestamps, otherwise the cloud-side strict LWW comparison rejects the second write. +func TestFestivalImageStampMonotonic(t *testing.T) { + a := festTestApp(t) + _ = a.store.SetMeta("sync_user_id", "1") + if _, e := a.SetFestivalImageData("端午", festPngDataURL(t, 6, 6, color.RGBA{10, 160, 90, 255})); e != nil { + t.Fatal(e) + } + var at1 string + _ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='端午'`).Scan(&at1) + if e := a.RemoveFestivalImage("端午"); e != nil { + t.Fatal(e) + } + var at2 string + _ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='端午'`).Scan(&at2) + if at2 <= at1 { + t.Fatalf("tombstone stamp %q must be greater than save stamp %q", at2, at1) + } +} + +func TestFestivalImageBadInput(t *testing.T) { + a := festTestApp(t) + _ = a.store.SetMeta("sync_user_id", "1") + if _, e := a.SetFestivalImageData("", festPngDataURL(t, 4, 4, color.White)); e == nil { + t.Fatal("expected error for empty key") + } + if _, e := a.SetFestivalImageData("春节", "data:image/png;base64,!!!"); e == nil { + t.Fatal("expected error for invalid base64 payload") + } + if _, e := a.SetFestivalImageData("春节", "hello"); e == nil { + t.Fatal("expected error for non-dataURL input") + } +} diff --git a/fileapi.go b/fileapi.go new file mode 100644 index 0000000..93bab19 --- /dev/null +++ b/fileapi.go @@ -0,0 +1,183 @@ +package main + +// fileapi.go 调 nl-pms-api 的最小客户端:上传图片换取公开访问 URL, +// 以及素材库的列表/删除透传(权限由服务端按 用户/团队角色 判定)。 +// 服务器地址与密钥来自管理员下发的全局文件存储配置(见 filestorage.go)。 +// 请求由 Go 端发起(不走前端 fetch),天然绕开 webview 的 CORS 限制。 + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" +) + +var fileAPIClient = &http.Client{Timeout: 30 * time.Second} + +// currentTeamID 返回客户端当前所在团队 id(未加入团队为 0)。 +func (a *App) currentTeamID() int64 { + n, _ := strconv.ParseInt(strings.TrimSpace(a.store.Meta("current_team_id")), 10, 64) + if n < 0 { + return 0 + } + return n +} + +// fileAPIBase 返回已启用的服务器存储地址(实时读全局配置,离线回本地缓存); +// 未启用返回 FILE_API_UNCONFIGURED。 +func (a *App) fileAPIBase() (FileStorageConfig, error) { + cfg := a.currentFileStorage() + if cfg.Mode != "server" || cfg.BaseURL == "" { + return cfg, errors.New("FILE_API_UNCONFIGURED") + } + return cfg, nil +} + +// fileAPIStatusErr 把 nl-pms-api 的非 200 响应映射为客户端错误码。 +func fileAPIStatusErr(status int) error { + switch status { + case http.StatusForbidden: + return errors.New("FILE_PERMISSION_DENIED") + case http.StatusNotFound: + return errors.New("FILE_NOT_FOUND") + default: + return errors.New("FILE_API_REQUEST_FAILED") + } +} + +// uploadImageToServer 把编码好的图片字节上传到 nl-pms-api,返回可公开访问的 http URL。 +// 归属:头像记个人(teamId=0),内容图记当前团队,素材库据此划定管理范围。 +// 未启用服务器存储时返回 FILE_API_UNCONFIGURED;失败不静默降级,由调用方向用户报错。 +func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error) { + cfg, e := a.fileAPIBase() + if e != nil { + return "", e + } + ext := ".jpg" + switch mime { + case "image/png": + ext = ".png" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, e := w.CreateFormFile("file", "img"+ext) + if e != nil { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + if _, e = fw.Write(data); e != nil { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + _ = w.WriteField("kind", kind) + if id := a.syncUserID(); id > 0 { + _ = w.WriteField("userId", strconv.FormatInt(id, 10)) + } + if kind == "content" { + _ = w.WriteField("teamId", strconv.FormatInt(a.currentTeamID(), 10)) + } + if e = w.Close(); e != nil { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + req, e := http.NewRequest(http.MethodPost, cfg.BaseURL+"/api/v1/files", &buf) + if e != nil { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + resp, e := fileAPIClient.Do(req) + if e != nil { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + var out struct { + URL string `json:"url"` + } + if json.Unmarshal(body, &out) != nil || out.URL == "" { + return "", errors.New("IMAGE_UPLOAD_FAILED") + } + return out.URL, nil +} + +// ListServerFiles 素材库分页列表。scope=mine 看自己;scope=team 看指定团队 +// (需为该团队 owner/admin);scope=all 看全部(仅云端账号 id=1)。 +func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileList, error) { + if e := a.ready(); e != nil { + return ServerFileList{}, e + } + cfg, e := a.fileAPIBase() + if e != nil { + return ServerFileList{}, e + } + uid := a.syncUserID() + if uid <= 0 { + return ServerFileList{}, errors.New("SYNC_NOT_LOGGED_IN") + } + if page < 1 { + page = 1 + } + u := fmt.Sprintf("%s/api/v1/files?scope=%s&userId=%d&teamId=%d&page=%d&pageSize=24", + cfg.BaseURL, scope, uid, teamID, page) + req, e := http.NewRequest(http.MethodGet, u, nil) + if e != nil { + return ServerFileList{}, errors.New("FILE_API_REQUEST_FAILED") + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + resp, e := fileAPIClient.Do(req) + if e != nil { + return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE") + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if resp.StatusCode != http.StatusOK { + return ServerFileList{}, fileAPIStatusErr(resp.StatusCode) + } + var out ServerFileList + if json.Unmarshal(body, &out) != nil { + return ServerFileList{}, errors.New("FILE_API_REQUEST_FAILED") + } + return out, nil +} + +// DeleteServerFile 删除素材(记录+服务器磁盘文件)。服务端校验:本人、 +// 超管 id=1、或该文件归属团队的 owner/admin;越权返回 FILE_PERMISSION_DENIED。 +func (a *App) DeleteServerFile(id int64) error { + if e := a.ready(); e != nil { + return e + } + cfg, e := a.fileAPIBase() + if e != nil { + return e + } + uid := a.syncUserID() + if uid <= 0 { + return errors.New("SYNC_NOT_LOGGED_IN") + } + u := fmt.Sprintf("%s/api/v1/files/%d?userId=%d", cfg.BaseURL, id, uid) + req, e := http.NewRequest(http.MethodDelete, u, nil) + if e != nil { + return errors.New("FILE_API_REQUEST_FAILED") + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + resp, e := fileAPIClient.Do(req) + if e != nil { + return errors.New("FILE_STORAGE_UNREACHABLE") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fileAPIStatusErr(resp.StatusCode) + } + return nil +} diff --git a/filestorage.go b/filestorage.go new file mode 100644 index 0000000..4b624c7 --- /dev/null +++ b/filestorage.go @@ -0,0 +1,175 @@ +package main + +// filestorage.go 全局「文件存储方式」配置:由管理员(云端账号 id=1)在设置页配置, +// 权威数据存远端 MySQL 的 sync_settings 表(user_id=1, name='file_storage' 行)。 +// 保存时直接写库立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存), +// 拉取失败(离线/未配置同步)回退本地缓存副本(由同步循环下发,见 syncFileStorageConfig)。 +// mode=local 时一切维持本地行为;mode=server 时内容图与头像选图会上传到 +// nl-pms-api 换取 http URL(远程/跨设备场景无法使用本地路径)。 + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "strings" + "time" +) + +const ( + fileStorageKey = "file_storage" // settings 表 KV 键(本地缓存,JSON) + fileStorageAtKey = "file_storage_updated_at" // LWW 时间戳,同步推拉的判定依据 + fileStorageTTL = 30 * time.Second // 实时配置的内存缓存时长,避免连续上传反复查库 +) + +// normalizeFileStorage 收敛非法值:mode 只认 local/server,地址去尾斜杠。 +func normalizeFileStorage(c FileStorageConfig) FileStorageConfig { + if c.Mode != "server" { + c.Mode = "local" + } + c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") + c.APIKey = strings.TrimSpace(c.APIKey) + return c +} + +// fileStorageConfig 读本地缓存的全局配置(未配置返回 local 零值)。 +func (a *App) fileStorageConfig() FileStorageConfig { + var c FileStorageConfig + if raw := a.store.Meta(fileStorageKey); raw != "" { + _ = json.Unmarshal([]byte(raw), &c) + } + return normalizeFileStorage(c) +} + +// fetchRemoteFileStorage 从远端 MySQL 读权威配置。第二个返回值表示"远端可用且结果权威" +// (无行记录视作权威的 local);成功时顺带刷新本地缓存副本,供离线兜底。 +func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) { + base := a.ctx + if base == nil { + base = context.Background() + } + ctx, cancel := context.WithTimeout(base, 5*time.Second) + defer cancel() + db, e := a.openRemote(ctx) + if e != nil { + return FileStorageConfig{}, false + } + defer db.Close() + var val, at string + e = db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name=?`, + festivalAdminID, fileStorageKey).Scan(&val, &at) + if e == sql.ErrNoRows { + return FileStorageConfig{}, true // 管理员尚未配置 → 权威的 local + } + if e != nil { + return FileStorageConfig{}, false + } + var c FileStorageConfig + if json.Unmarshal([]byte(val), &c) != nil { + return FileStorageConfig{}, false + } + c = normalizeFileStorage(c) + if b, e := json.Marshal(c); e == nil { + _ = a.store.SetMeta(fileStorageKey, string(b)) + _ = a.store.SetMeta(fileStorageAtKey, at) + } + return c, true +} + +// currentFileStorage 返回上传时应采用的全局配置:优先远端实时值(TTL 内存缓存), +// 远端不可达时回退本地缓存副本。管理员改完配置后各端最迟 TTL 内生效,无需等同步轮。 +// 失败结果同样缓存 TTL,避免离线时每次上传都白等一轮连接超时。 +func (a *App) currentFileStorage() FileStorageConfig { + a.fsMu.Lock() + if !a.fsCfgAt.IsZero() && time.Since(a.fsCfgAt) < fileStorageTTL { + c := a.fsCfg + a.fsMu.Unlock() + return c + } + a.fsMu.Unlock() + c, ok := a.fetchRemoteFileStorage() + if !ok { + c = a.fileStorageConfig() + } + a.fsMu.Lock() + a.fsCfg, a.fsCfgAt = c, time.Now() + a.fsMu.Unlock() + return c +} + +// invalidateFileStorageCache 让下一次读取强制回源(保存配置后调用)。 +func (a *App) invalidateFileStorageCache() { + a.fsMu.Lock() + a.fsCfgAt = time.Time{} + a.fsMu.Unlock() +} + +// GetFileStorageConfig 返回全局文件存储配置(远端优先,离线回本地缓存)。 +// 所有账号可读:界面据此决定头像/内容图的存储走向与素材库可用性。 +func (a *App) GetFileStorageConfig() (FileStorageConfig, error) { + if e := a.ready(); e != nil { + return FileStorageConfig{}, e + } + return a.currentFileStorage(), nil +} + +// SaveFileStorageConfig 管理员(id=1)保存全局配置:直接写远端 MySQL 立即全员生效, +// 同时更新本地缓存副本与 LWW 时间戳(同步循环不会再把旧值推回)。要求在线。 +func (a *App) SaveFileStorageConfig(c FileStorageConfig) error { + if e := a.ready(); e != nil { + return e + } + if a.syncUserID() != festivalAdminID { + return errors.New("FILE_STORAGE_ADMIN_ONLY") + } + c = normalizeFileStorage(c) + if c.Mode == "server" && !strings.HasPrefix(c.BaseURL, "http") { + return errors.New("FILE_STORAGE_BAD_URL") + } + b, e := json.Marshal(c) + if e != nil { + return e + } + ctx, cancel := context.WithTimeout(a.ctx, syncTimeout) + defer cancel() + db, e := a.openRemote(ctx) + if e != nil { + return e + } + defer db.Close() + now := nowRFC() + if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?) + ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)`, + festivalAdminID, fileStorageKey, string(b), now); e != nil { + return mapSyncErr(e) + } + if e := a.store.SetMeta(fileStorageKey, string(b)); e != nil { + return e + } + _ = a.store.SetMeta(fileStorageAtKey, now) + a.invalidateFileStorageCache() + a.store.Log("info", "系统", "文件存储配置已保存", "mode="+c.Mode) + return nil +} + +// TestFileStorage 探测服务器连通性(GET {baseURL}/healthz),配置界面「测试连接」用。 +func (a *App) TestFileStorage(c FileStorageConfig) error { + if e := a.ready(); e != nil { + return e + } + c = normalizeFileStorage(c) + if !strings.HasPrefix(c.BaseURL, "http") { + return errors.New("FILE_STORAGE_BAD_URL") + } + client := &http.Client{Timeout: 5 * time.Second} + resp, e := client.Get(c.BaseURL + "/healthz") + if e != nil { + return errors.New("FILE_STORAGE_UNREACHABLE") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New("FILE_STORAGE_UNREACHABLE") + } + return nil +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..c12c325 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000..c12c325 Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/AIBrief.vue b/frontend/src/components/AIBrief.vue new file mode 100644 index 0000000..e0db934 --- /dev/null +++ b/frontend/src/components/AIBrief.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/components/AIDayPanel.vue b/frontend/src/components/AIDayPanel.vue new file mode 100644 index 0000000..8103cba --- /dev/null +++ b/frontend/src/components/AIDayPanel.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/components/AIScopeDrawer.vue b/frontend/src/components/AIScopeDrawer.vue new file mode 100644 index 0000000..85acabc --- /dev/null +++ b/frontend/src/components/AIScopeDrawer.vue @@ -0,0 +1,101 @@ + + + diff --git a/frontend/src/components/AboutModal.vue b/frontend/src/components/AboutModal.vue new file mode 100644 index 0000000..3e07387 --- /dev/null +++ b/frontend/src/components/AboutModal.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/CommandPalette.vue b/frontend/src/components/CommandPalette.vue new file mode 100644 index 0000000..5fc62ae --- /dev/null +++ b/frontend/src/components/CommandPalette.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/components/DailyCard.vue b/frontend/src/components/DailyCard.vue new file mode 100644 index 0000000..7f847ee --- /dev/null +++ b/frontend/src/components/DailyCard.vue @@ -0,0 +1,141 @@ + + + diff --git a/frontend/src/components/DatePicker.vue b/frontend/src/components/DatePicker.vue new file mode 100644 index 0000000..64332db --- /dev/null +++ b/frontend/src/components/DatePicker.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/src/components/DueQuickPick.vue b/frontend/src/components/DueQuickPick.vue new file mode 100644 index 0000000..d1aacd3 --- /dev/null +++ b/frontend/src/components/DueQuickPick.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/components/FilePreviewModal.vue b/frontend/src/components/FilePreviewModal.vue new file mode 100644 index 0000000..720f474 --- /dev/null +++ b/frontend/src/components/FilePreviewModal.vue @@ -0,0 +1,123 @@ + + + diff --git a/frontend/src/components/LifecycleTimeline.vue b/frontend/src/components/LifecycleTimeline.vue new file mode 100644 index 0000000..924dba0 --- /dev/null +++ b/frontend/src/components/LifecycleTimeline.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/src/components/LoginModal.vue b/frontend/src/components/LoginModal.vue new file mode 100644 index 0000000..43c9fae --- /dev/null +++ b/frontend/src/components/LoginModal.vue @@ -0,0 +1,117 @@ + + + diff --git a/frontend/src/components/MarkdownView.vue b/frontend/src/components/MarkdownView.vue new file mode 100644 index 0000000..a7660d8 --- /dev/null +++ b/frontend/src/components/MarkdownView.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/MessageBell.vue b/frontend/src/components/MessageBell.vue new file mode 100644 index 0000000..4572c4f --- /dev/null +++ b/frontend/src/components/MessageBell.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/components/NoteCenter.vue b/frontend/src/components/NoteCenter.vue new file mode 100644 index 0000000..0dc42d5 --- /dev/null +++ b/frontend/src/components/NoteCenter.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/components/NoteModal.vue b/frontend/src/components/NoteModal.vue new file mode 100644 index 0000000..54707e7 --- /dev/null +++ b/frontend/src/components/NoteModal.vue @@ -0,0 +1,74 @@ + + +