From 47dfde96fab399d501a6986362ad05ec198cc7be Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 13:28:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E8=AE=BE=E7=BD=AE=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E2=80=9C=E5=85=B3=E4=BA=8E=E2=80=9D=E9=A1=B5=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=94=B9=E4=B8=BA=20Motty=20=E5=BC=8F?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E4=B8=8E=E4=B8=80=E9=94=AE=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=8C=E7=89=88=E6=9C=AC=E5=8C=BA=E5=88=86=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E7=89=88=E4=B8=8E=E9=A2=84=E8=A7=88=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置侧栏在 AI 之后新增一级页面“关于”:图标、名称、简介和复制按钮;版本信息 (版本、平台、构建、运行时);与更新页共用的更新卡片;加入交流(QQ 群、微信群 飞书页);项目(源码仓库、Issue)。不再显示 Core C ABI 版本。 - 更新参照 Motty:启动 5 秒后第一次检查,之后每小时一次;窗口在后台时推迟到回到 前台再补;同一版本只通知一次;“立即检查”与一键“更新”(下载、校验、安装、重启), 下载中可取消。项目标签页共用一份更新状态。 - 版本号来自 desktop/viva.toml。只有 Desktop Release 为 desktop-v<版本> 标签的提交 打包时传 package-desktop.sh --stable(lithe_stable 构建标签),应用内显示纯版本号; 其余构建显示“<版本> 预览版”。package-release.sh 校验标签在 HEAD 上且每个打包程序 都带 lithe_stable。 - 修复关于页在纯文字上挂 Tooltip 导致的崩溃。 - 设置窗口的失败带上真实操作名写日志(settings_about_copy、keymap_schemes、 editor_preferences、plugin_settings_surface),不再全部记成 plugin_settings_surface; 打开链接失败记 settings_open_link 并提示用户。 - 修复 TestProjectTreeCreatesRenamesAndDeletes 在高负载下偶发找不到“永久删除”: 打开对话框后请求重绘,并有界等待按钮出现再点击。 - 补关于页、更新器和欢迎页版本标识的测试,日志目录测试覆盖新增事件;更新设置分类 Note、发布 Skill、迁移文档和功能矩阵。 --- .../2026-10-15-macos-settings-hierarchy.md | 10 +- .agents/skills/release-lithe/SKILL.md | 8 + desktop/internal/buildinfo/buildinfo.go | 10 +- desktop/internal/buildinfo/preview.go | 6 + desktop/internal/buildinfo/stable.go | 6 + desktop/internal/i18n/app_messages.go | 10 +- desktop/internal/i18n/i18n_test.go | 12 +- desktop/internal/i18n/messages.go | 7 +- desktop/internal/i18n/shared.generated.go | 82 +++- desktop/internal/icons/icons.go | 117 +++++ desktop/internal/settingswindow/about_view.go | 314 ++++++++++++ .../settingswindow/about_view_test.go | 168 +++++++ desktop/internal/settingswindow/controller.go | 27 +- .../settingswindow/keymap_schemes_view.go | 4 +- desktop/internal/settingswindow/model.go | 6 + desktop/internal/settingswindow/palette.go | 1 + .../settingswindow/settings_surfaces.go | 17 +- .../settingswindow/settings_surfaces_test.go | 2 +- .../settingswindow/settingswindow_test.go | 14 +- .../internal/settingswindow/updates_view.go | 170 ++++--- .../settingswindow/visual_capture_test.go | 3 +- desktop/internal/settingswindow/window.go | 6 +- .../internal/settingswindow/window_view.go | 15 +- desktop/internal/workbench/app.go | 15 +- .../internal/workbench/filetree_menu_test.go | 10 +- desktop/internal/workbench/logevents_test.go | 11 + desktop/internal/workbench/settings.go | 32 +- desktop/internal/workbench/updates.go | 448 ++++++++++++++---- desktop/internal/workbench/updates_test.go | 189 ++++++++ desktop/internal/workbench/welcome_test.go | 5 +- desktop/internal/workbench/welcome_view.go | 3 +- desktop/internal/workbench/window_sessions.go | 5 + docs/viva-migration.md | 2 +- scripts/ci/desktop-release/package-release.sh | 36 +- scripts/desktop/generate-desktop-icons.sh | 2 +- scripts/desktop/package-desktop.sh | 21 +- shared/i18n/locales/en-US.json | 41 +- shared/i18n/locales/zh-CN.json | 41 +- shared/i18n/messages.json | 206 +++++++- .../features/settings-about-page.json | 46 ++ .../features/updates-check-download.json | 14 +- 41 files changed, 1828 insertions(+), 314 deletions(-) create mode 100644 desktop/internal/buildinfo/preview.go create mode 100644 desktop/internal/buildinfo/stable.go create mode 100644 desktop/internal/settingswindow/about_view.go create mode 100644 desktop/internal/settingswindow/about_view_test.go create mode 100644 desktop/internal/workbench/updates_test.go create mode 100644 shared/platform-feature-matrix/features/settings-about-page.json diff --git a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md index 60d11c710..dd06d2bfe 100644 --- a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md @@ -3,10 +3,12 @@ 状态:已实现 > Viva 迁移状态(2026-11-08):本文的分类规则继续作为目标,旧客户端的页面和实现细节不代表 Viva 已迁移。当前 Viva 设置侧栏的部分分组仍是占位;页面可用性以跨平台功能矩阵为准,新增页面仍按本规则归类。 +> +> Viva 更新(2026-11-14):Viva 设置侧栏在 AI 之后增加一级页面“关于”,固定在最后。Viva 的分组关系只写在 `desktop/internal/settingswindow/model.go` 的 `parents` 表一处,侧栏、面包屑和展开逻辑都从它推导,作用相当于旧客户端的 `SettingsGroup`。 ## 先说结论 -macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI。每个设置页必须属于其中一个分组,分组与页面的对应关系只写在 `SettingsGroup` 一处,侧栏、面包屑和“打开设置时展开所属分组”都从它推导。新增设置页前先按下面的归属规则选分组,不要再加一级入口。 +macOS 设置侧栏现在有七个一级分组入口:外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;最后是一级页面“关于”,它不是设置分组。每个设置页必须属于其中一个分组,分组与页面的对应关系只写在 `SettingsGroup` 一处,侧栏、面包屑和“打开设置时展开所属分组”都从它推导。新增设置页前先按下面的归属规则选分组,不要再加一级入口。 ## 问题 @@ -25,6 +27,7 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 | 语言与框架 | 语言服务(LSP)的开关,以及 Spring Boot 等框架的配置 | | 工具 | 终端等通用工具;插件带来的专用工具(例如 `.http` 请求)可以放进对应工具页。日志与诊断不属于设置,它是独立的日志窗口,见[结构化日志窗口](2026-11-03-structured-logging-log-window.md) | | AI | 凡是和 AI 有关的都放这里:MCP、Skill、全局指令、AI 提交信息、Agents、AI 服务商 | +| 关于 | 应用本身是什么:版本信息(版本、平台、构建、运行时)、更新卡片(与“外观与行为 › 更新”共用同一份状态和操作)、交流群和项目链接;一级页面,固定在侧栏最后,不放任何设置项 | 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 @@ -56,7 +59,8 @@ AI 分组里,Agent 可加载的资源(指令、技能、Hooks、MCP 配置 ### 不要这样做 -- 不要把页面直接加到 `SettingsSidebarItem.layout` 来绕开分组;一级页面只留给插件。 +- 不要把页面直接加到 `SettingsSidebarItem.layout` 来绕开分组;一级页面只留给插件和“关于”。 +- 不要把设置项放进“关于”。它只说明当前副本是什么、怎样更新、去哪里交流;更新的开关仍在“外观与行为 › 更新”。 - 不要在 `SettingsView` 里再写按页面分支的分组 `switch`,这正是本次删除的重复来源。 - 不要为某个语言或构建工具新增 `SettingsCategory`;让对应插件在清单里用 `page` 注册。 @@ -81,7 +85,7 @@ Issue 希望“构建工具 → Maven”“服务运行”“框架支持”也 - `SettingsSidebarLayoutTests` 保证每个页面恰好属于一个分组或是一级页面,并固定本表的分组内容与 AI 页面顺序。 - `SettingsSearchVocabularyTests` 保证每个页面仍能被检索到。 - 旧客户端的 [macOS 测试入口(历史版本)](https://github.com/1lck/Lithe-IDEA/blob/5a54c41aa37a4e6e6b68918a4b05765b9dd07a36/scripts/macos/test/test-macos.sh) 曾运行 `SettingsSidebarLayoutTests`;该检查不能证明 Viva 设置页已经对齐。 -- `scripts/desktop/desktop-go.sh test ./internal/settingswindow` 验证当前 Viva 已实现的侧栏和设置窗口行为,不能替代尚未迁移页面的验收。 +- `scripts/desktop/desktop-go.sh test ./internal/settingswindow` 验证当前 Viva 已实现的侧栏和设置窗口行为,包括“关于”排在 AI 之后、面包屑只有“关于”一级,不能替代尚未迁移页面的验收。 - 实机查看设置窗口的侧栏、面包屑、搜索展开和从其他入口跳转到某一页时展开所属分组。 ## 适用范围 diff --git a/.agents/skills/release-lithe/SKILL.md b/.agents/skills/release-lithe/SKILL.md index 5803d29b5..d80b0b67a 100644 --- a/.agents/skills/release-lithe/SKILL.md +++ b/.agents/skills/release-lithe/SKILL.md @@ -61,6 +61,14 @@ Apply these rules to every version number: a release as a prerelease when no stable host can use it yet, whatever its number. Keep the plain number and the plain tag (`v`, or `desktop-v` for the desktop app), with no `-beta` or similar suffix. +- The desktop app shows its version without the preview label only when the + Desktop Release built it for the commit of its `desktop-v` tag: + `scripts/ci/desktop-release/package-release.sh` packages with + `package-desktop.sh --stable`, which adds the `lithe_stable` build tag, and + fails when a packaged program lacks it. Every other build, including the + daily `desktop-AutoBuild` and local packages, shows the version with the + preview label. This label is independent of the GitHub prerelease flag. Pass + `--stable` nowhere else. - Never reuse a published version or publish a lower one. A published package is never modified; every change ships as a new version. The only exception is a version reset that the user explicitly requests for one artifact: diff --git a/desktop/internal/buildinfo/buildinfo.go b/desktop/internal/buildinfo/buildinfo.go index ac7e7d5f0..e549e7c86 100644 --- a/desktop/internal/buildinfo/buildinfo.go +++ b/desktop/internal/buildinfo/buildinfo.go @@ -1,6 +1,14 @@ -// Package buildinfo tells a development build from a release one. +// Package buildinfo tells a development build from a packaged one, and a stable release from +// a preview. package buildinfo // Development reports whether this binary has the dev build tag. The build // target in desktop/Makefile supplies the tag; packaged releases omit it. const Development = development + +// Stable reports whether this binary is a stable release: one packaged from the commit that +// carries its release tag, desktop-v with the version in desktop/viva.toml. +// scripts/desktop/package-desktop.sh adds the lithe_stable build tag only then. Every other +// build, the daily AutoBuild, a local package or a development build, is a preview, and Lithe +// says so next to the version. +const Stable = stable diff --git a/desktop/internal/buildinfo/preview.go b/desktop/internal/buildinfo/preview.go new file mode 100644 index 000000000..36154829f --- /dev/null +++ b/desktop/internal/buildinfo/preview.go @@ -0,0 +1,6 @@ +//go:build !lithe_stable + +package buildinfo + +// stable is false in a build that was not packaged from its release tag. +const stable = false diff --git a/desktop/internal/buildinfo/stable.go b/desktop/internal/buildinfo/stable.go new file mode 100644 index 000000000..4ac469f46 --- /dev/null +++ b/desktop/internal/buildinfo/stable.go @@ -0,0 +1,6 @@ +//go:build lithe_stable + +package buildinfo + +// stable is true in a build packaged from its release tag. +const stable = true diff --git a/desktop/internal/i18n/app_messages.go b/desktop/internal/i18n/app_messages.go index 845ca6728..00fbcdec6 100644 --- a/desktop/internal/i18n/app_messages.go +++ b/desktop/internal/i18n/app_messages.go @@ -4,11 +4,11 @@ package i18n func appMessages(language Language) AppMessages { return AppMessages{ Name: Shared(language, "native.lithe"), - Version: func(version string) string { - return SharedFormat(language, "desktop.app.version", map[string]string{"version": version}) - }, - Core: func(version string) string { - return SharedFormat(language, "desktop.app.coreVersion", map[string]string{"version": version}) + Version: func(version string, stable bool) string { + if stable { + return version + } + return SharedFormat(language, "desktop.app.previewVersion", map[string]string{"version": version}) }, DevBadge: Shared(language, "desktop.app.devBadge"), } diff --git a/desktop/internal/i18n/i18n_test.go b/desktop/internal/i18n/i18n_test.go index 4203b8116..343e572de 100644 --- a/desktop/internal/i18n/i18n_test.go +++ b/desktop/internal/i18n/i18n_test.go @@ -43,14 +43,13 @@ func TestSharedTemplateFormatsOnceAndPreservesUnknownPlaceholders(t *testing.T) func TestApplicationVersionUsesSharedLanguage(t *testing.T) { for _, test := range []struct { language Language - version string - core string + preview string }{ - {English, "Version 1.2.3", "Core 1.2.3"}, - {Chinese, "版本 1.2.3", "内核 1.2.3"}, + {English, "1.2.3 Preview"}, + {Chinese, "1.2.3 预览版"}, } { messages := For(test.language).App - if messages.Name != "Lithe" || messages.DevBadge != "Dev" || messages.Version("1.2.3") != test.version || messages.Core("1.2.3") != test.core { + if messages.Name != "Lithe" || messages.DevBadge != "Dev" || messages.Version("1.2.3", false) != test.preview || messages.Version("1.2.3", true) != "1.2.3" { t.Fatalf("language %s: application messages do not match the expected wording", test.language.Code()) } } @@ -327,6 +326,9 @@ func checkComplete(t *testing.T, path string, value reflect.Value) { args[index] = reflect.ValueOf("x") case reflect.Int: args[index] = reflect.ValueOf(2) + case reflect.Bool: + // false is the side a translation is made for, as Version's preview label. + args[index] = reflect.ValueOf(false) default: t.Fatalf("%s takes an unsupported parameter type", path) } diff --git a/desktop/internal/i18n/messages.go b/desktop/internal/i18n/messages.go index 0f48d0ede..e907793a6 100644 --- a/desktop/internal/i18n/messages.go +++ b/desktop/internal/i18n/messages.go @@ -39,10 +39,9 @@ type Messages struct { // AppMessages names the application. type AppMessages struct { Name string - // Version is the version line under the name, for example "Version 0.1.0". - Version func(version string) string - // Core is the core library line in the settings, for example "Core 0.1.0". - Core func(version string) string + // Version is the version as Lithe shows it: the plain version for a stable build, which + // was packaged from its desktop-v release tag, and "0.7.0 Preview" for any other. + Version func(version string, stable bool) string // DevBadge is the mark a development build puts on the application's Dock icon. DevBadge string } diff --git a/desktop/internal/i18n/shared.generated.go b/desktop/internal/i18n/shared.generated.go index 4d31e363b..c75189547 100644 --- a/desktop/internal/i18n/shared.generated.go +++ b/desktop/internal/i18n/shared.generated.go @@ -31,9 +31,8 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.aiCommit.responseTooLarge": "The AI provider response is larger than 2 MiB.", "desktop.aiCommit.stale": "The staged changes changed while the message was generated. Generate it again.", "desktop.aiCommit.timeout": "The AI provider did not answer within 45 seconds.", - "desktop.app.coreVersion": "Core {version}", "desktop.app.devBadge": "Dev", - "desktop.app.version": "Version {version}", + "desktop.app.previewVersion": "{version} Preview", "desktop.branch.alreadyOn": "Already on '{name}'", "desktop.branch.branchName": "Branch name", "desktop.branch.checkedOut": "Checked out '{name}'", @@ -1163,6 +1162,27 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.semantics.renameTitle": "Rename Symbol", "desktop.semantics.targetUnavailable": "The target is not a local file and cannot be opened yet.", "desktop.settings.about": "About", + "desktop.settings.about.build": "Build", + "desktop.settings.about.buildUnknown": "Not recorded", + "desktop.settings.about.community": "Community", + "desktop.settings.about.copied": "Copied", + "desktop.settings.about.copy": "Copy", + "desktop.settings.about.copyFailed": "Couldn't copy: {reason}", + "desktop.settings.about.copyHelp": "Copies the version, platform, build and runtime, ready to paste into an issue.", + "desktop.settings.about.info": "Version Info", + "desktop.settings.about.issues": "Report an Issue", + "desktop.settings.about.issuesHelp": "Report a bug or suggest a feature on GitHub.", + "desktop.settings.about.platform": "Platform", + "desktop.settings.about.project": "Project", + "desktop.settings.about.qq": "QQ Group", + "desktop.settings.about.qqHelp": "Group {number}: questions, feedback and chat.", + "desktop.settings.about.repo": "Source Code", + "desktop.settings.about.repoHelp": "Source, releases and discussions on GitHub.", + "desktop.settings.about.runtime": "Runtime", + "desktop.settings.about.tagline": "A low-memory IntelliJ IDEA alternative for Java development and AI collaboration", + "desktop.settings.about.version": "Version", + "desktop.settings.about.wechat": "WeChat Group", + "desktop.settings.about.wechatHelp": "Scan the QR code on the Feishu page to join.", "desktop.settings.aiGeneration.help": "The provider and options for commit message generation and inline edits. These are Lithe's own preferences: they affect only this app and do not change the Codex or Claude configuration.", "desktop.settings.aiGeneration.includeBody": "Generate a commit body", "desktop.settings.aiGeneration.language": "Output language", @@ -1299,21 +1319,22 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.settings.terminalShellHelp": "The shell a new terminal runs, including the one the Terminal tool window opens by itself. The ⌄ menu beside + opens a terminal in another installed shell.", "desktop.settings.terminalShellMissing": "{path} (not found)", "desktop.settings.updates.auto": "Check for updates automatically", - "desktop.settings.updates.available": "Version {version} is available.", + "desktop.settings.updates.autoHelp": "Checks 5 seconds after launch, then every hour. Checks pause while the window is in the background and catch up when it returns. The update server sees your IP address when Lithe checks.", + "desktop.settings.updates.available": "Lithe {version} is available.", "desktop.settings.updates.check": "Check Now", "desktop.settings.updates.checking": "Checking for updates…", - "desktop.settings.updates.download": "Download", - "desktop.settings.updates.downloading": "Downloading… {percent}%", + "desktop.settings.updates.downloading": "Downloading Lithe {version}… {percent}%", "desktop.settings.updates.failed": "Update failed: {reason}", "desktop.settings.updates.help": "Lithe checks a signed release feed and installs the new version after you confirm. The current version stays in place until the new one starts.", - "desktop.settings.updates.install": "Install and Restart", - "desktop.settings.updates.installing": "Installing the update…", + "desktop.settings.updates.installing": "Installing and restarting…", "desktop.settings.updates.lastChecked": "Last checked {time}", "desktop.settings.updates.notConfigured": "This build has no update feed. Install new versions from the release page.", "desktop.settings.updates.notes": "Release Notes", - "desktop.settings.updates.ready": "Version {version} is downloaded and ready to install.", + "desktop.settings.updates.ready": "Lithe {version} is downloaded. Click Update to install and restart.", + "desktop.settings.updates.row": "Check for Updates", "desktop.settings.updates.title": "Updates", - "desktop.settings.updates.upToDate": "Lithe is up to date.", + "desktop.settings.updates.upToDate": "Lithe {version} is up to date.", + "desktop.settings.updates.update": "Update", "desktop.startupSweep.body": "{arg1} processes started by an earlier run of Lithe are still running. Ending them stops them.", "desktop.startupSweep.end": "End them", "desktop.startupSweep.item": "{arg1} PID {arg2} {arg3}", @@ -1499,7 +1520,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.tools.moveTo": "Move to", "desktop.tools.structure": "Structure", "desktop.tools.withShortcut": "{name} {keys}", - "desktop.updates.available": "Lithe {version} is available. Open Settings > Updates to install it.", + "desktop.updates.available": "Lithe {version} is available. Open Settings > About to update.", "desktop.vcs.commitDirectly": "Commits are sent to the repository right away; there is no separate push.", "desktop.viewer.actualSize": "Actual Size", "desktop.viewer.binaryHint": "This file is not text. Its first bytes are shown in hex, read only.", @@ -2481,9 +2502,8 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.aiCommit.responseTooLarge": "AI 服务商的响应超过 2 MiB。", "desktop.aiCommit.stale": "生成期间暂存的更改发生了变化,请重新生成。", "desktop.aiCommit.timeout": "AI 服务商在 45 秒内没有响应。", - "desktop.app.coreVersion": "内核 {version}", "desktop.app.devBadge": "Dev", - "desktop.app.version": "版本 {version}", + "desktop.app.previewVersion": "{version} 预览版", "desktop.branch.alreadyOn": "已在“{name}”上", "desktop.branch.branchName": "分支名称", "desktop.branch.checkedOut": "已检出“{name}”", @@ -3613,6 +3633,27 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.semantics.renameTitle": "重命名符号", "desktop.semantics.targetUnavailable": "目标不是本地文件,暂时无法打开。", "desktop.settings.about": "关于", + "desktop.settings.about.build": "构建", + "desktop.settings.about.buildUnknown": "未记录", + "desktop.settings.about.community": "加入交流", + "desktop.settings.about.copied": "已复制", + "desktop.settings.about.copy": "复制", + "desktop.settings.about.copyFailed": "复制失败:{reason}", + "desktop.settings.about.copyHelp": "复制版本、平台、构建和运行时,可直接粘贴到问题反馈里。", + "desktop.settings.about.info": "版本信息", + "desktop.settings.about.issues": "问题反馈", + "desktop.settings.about.issuesHelp": "在 GitHub 上报告问题或提功能建议。", + "desktop.settings.about.platform": "运行平台", + "desktop.settings.about.project": "项目", + "desktop.settings.about.qq": "QQ 群", + "desktop.settings.about.qqHelp": "群号 {number},提问、反馈和交流都可以。", + "desktop.settings.about.repo": "开源仓库", + "desktop.settings.about.repoHelp": "源码、发布版本和讨论都在 GitHub。", + "desktop.settings.about.runtime": "运行时", + "desktop.settings.about.tagline": "面向 Java 开发与 AI 协作的低内存占用 IntelliJ IDEA 替代品", + "desktop.settings.about.version": "版本", + "desktop.settings.about.wechat": "微信群", + "desktop.settings.about.wechatHelp": "在飞书文档里扫码加入。", "desktop.settings.aiGeneration.help": "提交信息生成和行内编辑使用的服务商与选项。这些是 Lithe 自己的偏好,只影响本应用,不改写 Codex 或 Claude 的配置。", "desktop.settings.aiGeneration.includeBody": "生成提交正文", "desktop.settings.aiGeneration.language": "输出语言", @@ -3749,21 +3790,22 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.settings.terminalShellHelp": "新建终端时运行的 Shell,也包括终端工具窗口自动打开的那个。+ 旁边的 ⌄ 菜单可以用其他已安装的 Shell 新建终端。", "desktop.settings.terminalShellMissing": "{path}(未找到)", "desktop.settings.updates.auto": "自动检查更新", - "desktop.settings.updates.available": "有新版本 {version} 可用。", + "desktop.settings.updates.autoHelp": "启动 5 秒后检查一次,之后每小时一次;窗口在后台时暂停,回到前台再补查。检查时更新服务器会看到你的 IP 地址。", + "desktop.settings.updates.available": "Lithe {version} 可更新。", "desktop.settings.updates.check": "立即检查", "desktop.settings.updates.checking": "正在检查更新…", - "desktop.settings.updates.download": "下载", - "desktop.settings.updates.downloading": "正在下载… {percent}%", + "desktop.settings.updates.downloading": "正在下载 Lithe {version}… {percent}%", "desktop.settings.updates.failed": "更新失败:{reason}", "desktop.settings.updates.help": "Lithe 从带签名的发布源检查新版本,经你确认后安装。新版本启动前,当前版本保持不变。", - "desktop.settings.updates.install": "安装并重启", - "desktop.settings.updates.installing": "正在安装更新…", + "desktop.settings.updates.installing": "正在安装并重启…", "desktop.settings.updates.lastChecked": "上次检查:{time}", "desktop.settings.updates.notConfigured": "此构建未配置更新源,请从发布页面安装新版本。", "desktop.settings.updates.notes": "发布说明", - "desktop.settings.updates.ready": "版本 {version} 已下载,可以安装。", + "desktop.settings.updates.ready": "Lithe {version} 已下载,点“更新”即可安装并重启。", + "desktop.settings.updates.row": "检查更新", "desktop.settings.updates.title": "更新", - "desktop.settings.updates.upToDate": "Lithe 已是最新版本。", + "desktop.settings.updates.upToDate": "Lithe {version} 已是最新版本。", + "desktop.settings.updates.update": "更新", "desktop.startupSweep.body": "上次运行 Lithe 启动的 {arg1} 个进程仍在运行。结束它们会停止这些进程。", "desktop.startupSweep.end": "结束这些进程", "desktop.startupSweep.item": "{arg1} PID {arg2} {arg3}", @@ -3949,7 +3991,7 @@ var sharedCatalogs = map[string]map[string]string{ "desktop.tools.moveTo": "移动到", "desktop.tools.structure": "结构", "desktop.tools.withShortcut": "{name} {keys}", - "desktop.updates.available": "Lithe {version} 已发布,可在“设置 > 更新”中安装。", + "desktop.updates.available": "Lithe {version} 已发布,可在“设置 > 关于”中一键更新。", "desktop.vcs.commitDirectly": "提交会立即发送到仓库,没有单独的推送步骤。", "desktop.viewer.actualSize": "实际大小", "desktop.viewer.binaryHint": "此文件不是文本。以下以十六进制只读显示其开头部分。", diff --git a/desktop/internal/icons/icons.go b/desktop/internal/icons/icons.go index 4c3627d10..7cb399eca 100644 --- a/desktop/internal/icons/icons.go +++ b/desktop/internal/icons/icons.go @@ -752,6 +752,29 @@ var Eraser = gfx.Icon{ }, } +// external-link is a compiled vector icon. +var ExternalLink = gfx.Icon{ + ViewBox: 24.0, + Stroke: 2.0, + Cap: gfx.CapRound, + Join: gfx.JoinRound, + Commands: []gfx.PathCommand{ + gfx.MoveTo(15.0, 3.0), + gfx.LineTo(21.0, 3.0), + gfx.LineTo(21.0, 9.0), + gfx.MoveTo(10.0, 14.0), + gfx.LineTo(21.0, 3.0), + gfx.MoveTo(18.0, 13.0), + gfx.LineTo(18.0, 19.0), + gfx.CubicTo(18.0, 20.1046, 17.1046, 21.0, 16.0, 21.0), + gfx.LineTo(5.0, 21.0), + gfx.CubicTo(3.89543, 21.0, 3.0, 20.1046, 3.0, 19.0), + gfx.LineTo(3.0, 8.0), + gfx.CubicTo(3.0, 6.89543, 3.89543, 6.0, 5.0, 6.0), + gfx.LineTo(11.0, 6.0), + }, +} + // eye is a compiled vector icon. var Eye = gfx.Icon{ ViewBox: 24.0, @@ -1079,6 +1102,33 @@ var GitCompare = gfx.Icon{ }, } +// github is a compiled vector icon. +var Github = gfx.Icon{ + ViewBox: 24.0, + Stroke: 2.0, + Cap: gfx.CapRound, + Join: gfx.JoinRound, + Commands: []gfx.PathCommand{ + gfx.MoveTo(15.0, 22.0), + gfx.LineTo(15.0, 18.0), + gfx.CubicTo(15.1391, 16.7473, 14.7799, 15.4901, 14.0, 14.5), + gfx.CubicTo(17.0, 14.5, 20.0, 12.5, 20.0, 9.0), + gfx.CubicTo(20.08, 7.75, 19.73, 6.52, 19.0, 5.5), + gfx.CubicTo(19.28, 4.35, 19.28, 3.15, 19.0, 2.0), + gfx.CubicTo(19.0, 2.0, 18.0, 2.0, 16.0, 3.5), + gfx.CubicTo(13.36, 3.0, 10.64, 3.0, 8.0, 3.5), + gfx.CubicTo(6.0, 2.0, 5.0, 2.0, 5.0, 2.0), + gfx.CubicTo(4.7, 3.15, 4.7, 4.35, 5.0, 5.5), + gfx.CubicTo(4.27187, 6.51588, 3.91847, 7.75279, 4.0, 9.0), + gfx.CubicTo(4.0, 12.5, 7.0, 14.5, 10.0, 14.5), + gfx.CubicTo(9.61, 14.99, 9.32, 15.55, 9.15, 16.15), + gfx.CubicTo(8.98, 16.75, 8.93, 17.38, 9.0, 18.0), + gfx.LineTo(9.0, 22.0), + gfx.MoveTo(9.0, 18.0), + gfx.CubicTo(4.49, 20.0, 4.0, 16.0, 2.0, 16.0), + }, +} + // history is a compiled vector icon. var History = gfx.Icon{ ViewBox: 24.0, @@ -1306,6 +1356,50 @@ var LockOpen = gfx.Icon{ }, } +// message-circle is a compiled vector icon. +var MessageCircle = gfx.Icon{ + ViewBox: 24.0, + Stroke: 2.0, + Cap: gfx.CapRound, + Join: gfx.JoinRound, + Commands: []gfx.PathCommand{ + gfx.MoveTo(7.9, 20.0), + gfx.CubicTo(11.8432, 22.0227, 16.672, 20.8739, 19.2818, 17.2922), + gfx.CubicTo(21.8915, 13.7104, 21.5055, 8.76188, 18.3718, 5.62819), + gfx.CubicTo(15.2381, 2.49451, 10.2896, 2.10846, 6.70782, 4.71825), + gfx.CubicTo(3.12607, 7.32804, 1.97725, 12.1568, 4.0, 16.1), + gfx.LineTo(2.0, 22.0), + gfx.ClosePath(), + }, +} + +// messages-square is a compiled vector icon. +var MessagesSquare = gfx.Icon{ + ViewBox: 24.0, + Stroke: 2.0, + Cap: gfx.CapRound, + Join: gfx.JoinRound, + Commands: []gfx.PathCommand{ + gfx.MoveTo(14.0, 9.0), + gfx.CubicTo(14.0, 10.1046, 13.1046, 11.0, 12.0, 11.0), + gfx.LineTo(6.0, 11.0), + gfx.LineTo(2.0, 15.0), + gfx.LineTo(2.0, 4.0), + gfx.CubicTo(2.0, 2.89543, 2.89543, 2.0, 4.0, 2.0), + gfx.LineTo(12.0, 2.0), + gfx.CubicTo(13.1046, 2.0, 14.0, 2.89543, 14.0, 4.0), + gfx.ClosePath(), + gfx.MoveTo(18.0, 9.0), + gfx.LineTo(20.0, 9.0), + gfx.CubicTo(21.1046, 9.0, 22.0, 9.89543, 22.0, 11.0), + gfx.LineTo(22.0, 22.0), + gfx.LineTo(18.0, 18.0), + gfx.LineTo(12.0, 18.0), + gfx.CubicTo(10.8954, 18.0, 10.0, 17.1046, 10.0, 16.0), + gfx.LineTo(10.0, 15.0), + }, +} + // minus is a compiled vector icon. var Minus = gfx.Icon{ ViewBox: 24.0, @@ -1318,6 +1412,29 @@ var Minus = gfx.Icon{ }, } +// monitor is a compiled vector icon. +var Monitor = gfx.Icon{ + ViewBox: 24.0, + Stroke: 2.0, + Cap: gfx.CapRound, + Join: gfx.JoinRound, + Commands: []gfx.PathCommand{ + gfx.MoveTo(20.0, 3.0), + gfx.CubicTo(21.1046, 3.0, 22.0, 3.89543, 22.0, 5.0), + gfx.LineTo(22.0, 15.0), + gfx.CubicTo(22.0, 16.1046, 21.1046, 17.0, 20.0, 17.0), + gfx.LineTo(4.0, 17.0), + gfx.CubicTo(2.89543, 17.0, 2.0, 16.1046, 2.0, 15.0), + gfx.LineTo(2.0, 5.0), + gfx.CubicTo(2.0, 3.89543, 2.89543, 3.0, 4.0, 3.0), + gfx.ClosePath(), + gfx.MoveTo(8.0, 21.0), + gfx.LineTo(16.0, 21.0), + gfx.MoveTo(12.0, 17.0), + gfx.LineTo(12.0, 21.0), + }, +} + // panel-right is a compiled vector icon. var PanelRight = gfx.Icon{ ViewBox: 24.0, diff --git a/desktop/internal/settingswindow/about_view.go b/desktop/internal/settingswindow/about_view.go new file mode 100644 index 000000000..cb767805a --- /dev/null +++ b/desktop/internal/settingswindow/about_view.go @@ -0,0 +1,314 @@ +package settingswindow + +import ( + "fmt" + "runtime" + "runtime/debug" + "strings" + "time" + + "github.com/1lck/Lithe-IDEA/desktop/internal/appicon" + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" + "github.com/1lck/Lithe-IDEA/desktop/internal/icons" + "github.com/1lck/Lithe-IDEA/desktop/internal/sysinfo" + "github.com/vivaui/viva/app" + "github.com/vivaui/viva/gfx" + "github.com/vivaui/viva/ui" +) + +// The About page's links. They are the ones the project's README gives, so the page and the +// README send people to the same groups and the same repository. +const ( + aboutQQGroup = "163027877" + aboutQQURL = "https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=" + aboutQQGroup + aboutWeChatURL = "https://gcnctzuuwe9u.feishu.cn/wiki/HJFbwZ0hZirAPnkWF3xcPWkCnid?from=from_copylink" + aboutRepoURL = "https://github.com/1lck/Lithe-IDEA" + aboutIssuesURL = aboutRepoURL + "/issues" +) + +// aboutIconSize is the logo at the head of the page; aboutChip the coloured square of a +// link and aboutGlyph the glyph in it. +const ( + aboutIconSize = 56 + aboutChip = 26 + aboutGlyph = 14 +) + +// The colours of the link chips: each service's own, so the mark is recognised before +// its name is read. The glyph on them is white in both looks. +var ( + aboutQQColor = gfx.RGB24(0x12b7f5) + aboutWeChatColor = gfx.RGB24(0x07c160) + aboutIssuesColor = gfx.RGB24(0xc0554e) + aboutRepoColor = gfx.RGB24(0x24292f) +) + +// copyText puts text on the clipboard; a test replaces it. +var copyText = app.SetClipboardText + +// aboutState is what the About page remembers while it is open: that its facts were just +// copied, or why copying them failed. Leaving the page forgets both. +type aboutState struct { + copied bool + failure string +} + +// aboutFacts are what the About page says about the running client. None of them changes +// while it runs, so a window reads them once, not on every paint. +type aboutFacts struct { + // platform is the operating system and processor, as "macOS · arm64". + platform string + // build is the commit the client was built from and its date, empty when the build did + // not record them. + build string + // runtime is the Go runtime the client was built with. + runtime string + // copy is all of it as one block for an issue, with the full system version. + copy string +} + +// aboutInputs are what aboutFacts are made from, read from the process by +// currentAboutInputs and given by hand in tests. +type aboutInputs struct { + version string + stable bool + osName, osVersion string + arch, goVersion string + settings []debug.BuildSetting +} + +// currentAboutInputs reads aboutInputs from this process. +func currentAboutInputs(version string, stable bool) aboutInputs { + in := aboutInputs{ + version: version, stable: stable, + osName: sysinfo.Name(), osVersion: sysinfo.Version(), + arch: runtime.GOARCH, goVersion: runtime.Version(), + } + if info, ok := debug.ReadBuildInfo(); ok { + in.settings = info.Settings + } + return in +} + +// readAboutFacts makes the page's facts. The block it copies is for an issue, which the +// maintainers read whatever the language of the person who files it, so its labels are +// the fixed English ones the bug template asks for rather than interface text. +func readAboutFacts(in aboutInputs) aboutFacts { + facts := aboutFacts{platform: aboutPlatform(in.osName, in.arch), runtime: in.goVersion} + var revision, when string + for _, setting := range in.settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.time": + when = setting.Value + } + } + if len(revision) > 7 { + revision = revision[:7] + } + if revision != "" { + facts.build = revision + if stamp, err := time.Parse(time.RFC3339, when); err == nil { + facts.build += " · " + stamp.UTC().Format("2006-01-02") + } + } + var copy strings.Builder + if in.stable { + fmt.Fprintf(©, "Lithe %s\n", in.version) + } else { + fmt.Fprintf(©, "Lithe %s (preview)\n", in.version) + } + fmt.Fprintf(©, "platform: %s\n", aboutPlatform(strings.TrimSpace(in.osName+" "+in.osVersion), in.arch)) + if facts.build != "" { + fmt.Fprintf(©, "build: %s\n", facts.build) + } + fmt.Fprintf(©, "runtime: %s\n", in.goVersion) + facts.copy = copy.String() + return facts +} + +// aboutPlatform names the system and processor as a person reads them. +func aboutPlatform(system, arch string) string { + if system == "" { + return aboutArch(arch) + } + return system + " · " + aboutArch(arch) +} + +// aboutArch is the processor's usual name, keeping Go's own for anything else. +func aboutArch(arch string) string { + switch arch { + case "amd64": + return "x86-64" + case "386": + return "x86" + } + return arch +} + +// facts are the About page's facts, read on its first paint in this window. +func (w *window) facts() *aboutFacts { + if w.aboutFacts == nil { + facts := readAboutFacts(currentAboutInputs(w.ctrl.opts.Version, w.ctrl.opts.Stable)) + w.aboutFacts = &facts + } + return w.aboutFacts +} + +// aboutPage is the About page, the last of the sidebar: what this copy of Lithe is, its +// updates, and where its community and code are. +func (w *window) aboutPage(c ui.C) { + language := w.marketLanguage() + facts := w.facts() + w.pageScroll(c, pageAbout, func() { + w.aboutHeader(c, language, facts) + w.card(c, i18n.Shared(language, "desktop.settings.about.info"), "", func() { + build := facts.build + if build == "" { + build = i18n.Shared(language, "desktop.settings.about.buildUnknown") + } + ui.Column(c).FillWidth().Gap(4).PadY(3).Children(func() { + w.aboutPair(c, + func(c ui.C) { + w.aboutFact(c, icons.Info, i18n.Shared(language, "desktop.settings.about.version"), + w.text.App.Version(w.ctrl.opts.Version, w.ctrl.opts.Stable)) + }, + func(c ui.C) { + w.aboutFact(c, icons.Monitor, i18n.Shared(language, "desktop.settings.about.platform"), facts.platform) + }) + w.aboutPair(c, + func(c ui.C) { + w.aboutFact(c, icons.Wrench, i18n.Shared(language, "desktop.settings.about.build"), build) + }, + func(c ui.C) { + w.aboutFact(c, icons.SquareTerminal, i18n.Shared(language, "desktop.settings.about.runtime"), facts.runtime) + }) + }) + }) + if w.ctrl.opts.UpdateStatus != nil { + w.updateCard(c) + } + w.card(c, i18n.Shared(language, "desktop.settings.about.community"), "", func() { + ui.Column(c).FillWidth().PadY(3).Children(func() { + w.aboutPair(c, + func(c ui.C) { + w.aboutLink(c, icons.MessageCircle, aboutQQColor, i18n.Shared(language, "desktop.settings.about.qq"), + i18n.SharedFormat(language, "desktop.settings.about.qqHelp", map[string]string{"number": aboutQQGroup}), aboutQQURL) + }, + func(c ui.C) { + w.aboutLink(c, icons.MessagesSquare, aboutWeChatColor, i18n.Shared(language, "desktop.settings.about.wechat"), + i18n.Shared(language, "desktop.settings.about.wechatHelp"), aboutWeChatURL) + }) + }) + }) + w.card(c, i18n.Shared(language, "desktop.settings.about.project"), "", func() { + ui.Column(c).FillWidth().PadY(3).Children(func() { + w.aboutPair(c, + func(c ui.C) { + w.aboutLink(c, icons.Github, aboutRepoColor, i18n.Shared(language, "desktop.settings.about.repo"), + i18n.Shared(language, "desktop.settings.about.repoHelp"), aboutRepoURL) + }, + func(c ui.C) { + w.aboutLink(c, icons.Bug, aboutIssuesColor, i18n.Shared(language, "desktop.settings.about.issues"), + i18n.Shared(language, "desktop.settings.about.issuesHelp"), aboutIssuesURL) + }) + }) + }) + }) +} + +// aboutHeader is the head of the page: the logo, the name and what Lithe is for, with the +// button that copies the facts for an issue. +func (w *window) aboutHeader(c ui.C, language i18n.Language, facts *aboutFacts) { + p := &w.pal + ui.Column(c).FillWidth().Shrink(0).MinWidth(0).Pad(16).Gap(8).Radius(cardRadius).Background(p.card).Stroke(0.5, p.line).Children(func() { + ui.Row(c).FillWidth().Gap(14).Items(ui.Center).Children(func() { + ui.Image(c, appicon.Welcome()).Size(aboutIconSize, aboutIconSize).Shrink(0) + ui.Column(c).Grow(1).Shrink(1).MinWidth(0).Gap(3).Children(func() { + ui.Text(c, w.text.App.Name).FontSize(18).Weight(gfx.WeightSemibold).Color(p.text).Ellipsis() + ui.Wrap(c, i18n.Shared(language, "desktop.settings.about.tagline")).FontSize(12).Color(p.muted) + }) + label, glyph := i18n.Shared(language, "desktop.settings.about.copy"), icons.Copy + if w.about.copied { + label, glyph = i18n.Shared(language, "desktop.settings.about.copied"), icons.Check + } + if ui.Button(c.Part("copy"), label).Icon(glyph).Outline().Shrink(0). + Tooltip(i18n.Shared(language, "desktop.settings.about.copyHelp")).Clicked() { + w.copyAbout(facts) + } + }) + if w.about.failure != "" { + ui.Wrap(c, i18n.SharedFormat(language, "desktop.settings.about.copyFailed", map[string]string{"reason": w.about.failure})). + FontSize(11).Color(p.danger) + } + }) +} + +// copyAbout puts the facts on the clipboard. A failure is logged as settings_about_copy and +// said under the button until the next try. +func (w *window) copyAbout(facts *aboutFacts) { + if err := copyText(facts.copy); err != nil { + w.about = aboutState{failure: err.Error()} + reportSettingsError(w.ctrl.opts, "settings_about_copy", err) + return + } + w.about = aboutState{copied: true} +} + +// aboutPair lays two tiles side by side, or one under the other when the card is too +// narrow for two. +func (w *window) aboutPair(c ui.C, first, second func(ui.C)) { + if w.extent-2*contentPad-2*cardPad < 460 { + ui.Column(c).FillWidth().Gap(4).Children(func() { + for _, tile := range [2]func(ui.C){first, second} { + ui.Column(c).FillWidth().MinWidth(0).Children(func() { tile(c) }) + } + }) + return + } + // Each tile starts from no width and grows, so the two halves are equal whatever + // their words are. + ui.Row(c).FillWidth().Gap(10).Children(func() { + for _, tile := range [2]func(ui.C){first, second} { + ui.Column(c).Grow(1).Basis(0).Shrink(1).MinWidth(0).Children(func() { tile(c) }) + } + }) +} + +// aboutFact is one fact of the build: an icon and its label over the value. The values are +// short; one cut off in a narrow window is whole in the block Copy makes. +func (w *window) aboutFact(c ui.C, icon gfx.Icon, label, value string) { + p := &w.pal + ui.Row(c).FillWidth().MinHeight(44).PadX(4).Gap(10).Items(ui.Center).Children(func() { + ui.Row(c).Size(aboutChip, aboutChip).Shrink(0).Radius(6).Background(p.hover).Center().Children(func() { + ui.Icon(c, icon).Size(aboutGlyph, aboutGlyph).Color(p.muted) + }) + ui.Column(c).Grow(1).Shrink(1).MinWidth(0).Gap(1).Children(func() { + ui.Text(c, label).FontSize(11).Color(p.muted).Ellipsis() + ui.Text(c, value).FontSize(13).Weight(gfx.WeightMedium).Color(p.text).Ellipsis() + }) + }) +} + +// aboutLink is one link of the page: the service's mark, its name over what it is for, and +// the glyph of a page that opens in the browser. The whole tile is the link. +func (w *window) aboutLink(c ui.C, icon gfx.Icon, chip gfx.Color, label, help, url string) { + p := &w.pal + link := ui.Row(c).FillWidth().MinHeight(52).PadX(8).PadY(8).Gap(10).Items(ui.Center).Radius(8). + HitWith(ui.ID(c), label, gfx.HitOptions{Role: gfx.RoleLink, Cursor: gfx.PointerCursorHand, Description: help}). + Hover(ui.Look{}.Background(p.hover)).Focus(ui.Look{}.Ring(2, p.accent)) + link.Children(func() { + ui.Row(c).Size(aboutChip, aboutChip).Shrink(0).Radius(6).Background(chip).Center().Children(func() { + ui.Icon(c, icon).Size(aboutGlyph, aboutGlyph).Color(p.white) + }) + ui.Column(c).Grow(1).Shrink(1).MinWidth(0).Gap(2).Children(func() { + ui.Text(c, label).FontSize(13).Weight(gfx.WeightMedium).Color(p.text).Ellipsis() + ui.Paragraph(c, help, 2).FontSize(11).Color(p.muted) + }) + ui.Icon(c, icons.ExternalLink).Size(12, 12).Color(p.subtle).Shrink(0) + }) + if link.Clicked() && w.ctrl.opts.OpenURL != nil { + w.ctrl.opts.OpenURL(url) + } +} diff --git a/desktop/internal/settingswindow/about_view_test.go b/desktop/internal/settingswindow/about_view_test.go new file mode 100644 index 000000000..16bd00f64 --- /dev/null +++ b/desktop/internal/settingswindow/about_view_test.go @@ -0,0 +1,168 @@ +package settingswindow + +import ( + "errors" + "runtime/debug" + "testing" + "time" + + "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" +) + +// TestAboutFactsSayWhatThisCopyIs checks the facts the About page reads: the platform as a +// person names it, the commit and day of the build when the build recorded them, the Go +// runtime, and the block copied for an issue, whose first line says a preview is one. +func TestAboutFactsSayWhatThisCopyIs(t *testing.T) { + recorded := []debug.BuildSetting{ + {Key: "vcs.revision", Value: "0123456789abcdef"}, + {Key: "vcs.time", Value: "2026-11-13T22:15:00+08:00"}, + } + for _, tc := range []struct { + name string + in aboutInputs + facts aboutFacts + }{ + { + name: "preview", + in: aboutInputs{version: "0.7.0", osName: "macOS", osVersion: "26.1", arch: "arm64", + goVersion: "go1.26.1", settings: recorded}, + facts: aboutFacts{platform: "macOS · arm64", build: "0123456 · 2026-11-13", runtime: "go1.26.1", + copy: "Lithe 0.7.0 (preview)\nplatform: macOS 26.1 · arm64\nbuild: 0123456 · 2026-11-13\nruntime: go1.26.1\n"}, + }, + { + name: "stable", + in: aboutInputs{version: "0.7.0", stable: true, osName: "Windows", osVersion: "11", arch: "amd64", + goVersion: "go1.26.1", settings: recorded}, + facts: aboutFacts{platform: "Windows · x86-64", build: "0123456 · 2026-11-13", runtime: "go1.26.1", + copy: "Lithe 0.7.0\nplatform: Windows 11 · x86-64\nbuild: 0123456 · 2026-11-13\nruntime: go1.26.1\n"}, + }, + { + // go run and go test record no commit; the page says so rather than a blank. + name: "unrecorded", + in: aboutInputs{version: "0.7.0", arch: "386", goVersion: "go1.26.1"}, + facts: aboutFacts{platform: "x86", runtime: "go1.26.1", + copy: "Lithe 0.7.0 (preview)\nplatform: x86\nruntime: go1.26.1\n"}, + }, + { + // A commit without a readable time keeps the commit alone. + name: "commit only", + in: aboutInputs{version: "0.7.0", stable: true, osName: "Linux", arch: "riscv64", goVersion: "go1.26.1", + settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "abc"}, {Key: "vcs.time", Value: "yesterday"}}}, + facts: aboutFacts{platform: "Linux · riscv64", build: "abc", runtime: "go1.26.1", + copy: "Lithe 0.7.0\nplatform: Linux · riscv64\nbuild: abc\nruntime: go1.26.1\n"}, + }, + } { + if got := readAboutFacts(tc.in); got != tc.facts { + t.Errorf("%s: facts = %+v, want %+v", tc.name, got, tc.facts) + } + } +} + +// TestAboutPageShowsThePreviewAndCopiesTheFacts opens the About page of a preview build: it +// shows the version with its preview label, its links open their pages, and Copy puts the +// facts on the clipboard and says so, or says why it could not and reports the failure. +func TestAboutPageShowsThePreviewAndCopiesTheFacts(t *testing.T) { + saved := copyText + t.Cleanup(func() { copyText = saved }) + var copied []string + copyFailure := error(nil) + copyText = func(text string) error { + if copyFailure != nil { + return copyFailure + } + copied = append(copied, text) + return nil + } + type failure struct { + operation string + err error + } + var opened []string + var reported []failure + w := newWindow(NewController(Options{ + Version: "0.7.0", + OpenURL: func(url string) { opened = append(opened, url) }, + SettingsError: func(operation string, err error) { reported = append(reported, failure{operation, err}) }, + }), Values{}) + w.sess.page = pageAbout + d := openWindow(t, w) + text := func(id string) string { return i18n.Shared(i18n.English, id) } + + if !d.text("0.7.0 Preview") { + t.Fatal("the About page does not show the version as 0.7.0 Preview") + } + d.click(text("desktop.settings.about.qq")) + d.click(text("desktop.settings.about.issues")) + if want := []string{aboutQQURL, aboutIssuesURL}; len(opened) != 2 || opened[0] != want[0] || opened[1] != want[1] { + t.Fatalf("opened %q, want %q", opened, want) + } + + d.click(text("desktop.settings.about.copy")) + d.pump() + if len(copied) != 1 || copied[0] != w.facts().copy || !d.has(text("desktop.settings.about.copied")) { + t.Fatalf("copied %q and the button says Copied = %v, want the facts once", copied, d.has(text("desktop.settings.about.copied"))) + } + + copyFailure = errors.New("no clipboard") + d.click(text("desktop.settings.about.copied")) + d.pump() + failed := i18n.SharedFormat(i18n.English, "desktop.settings.about.copyFailed", map[string]string{"reason": "no clipboard"}) + if !d.text(failed) || !d.has(text("desktop.settings.about.copy")) || len(reported) != 1 || + reported[0].operation != "settings_about_copy" || !errors.Is(reported[0].err, copyFailure) { + t.Fatalf("after a failed copy the page says %q = %v and reported %v, want the reason shown and reported once as settings_about_copy", failed, d.text(failed), reported) + } +} + +// TestUpdatesPageNamesTheRunningVersionAsItIsReleased shows the running version on the Updates +// page, with the preview label unless the build is a stable release, in both the card of the +// current version and the line that says it is up to date. +func TestUpdatesPageNamesTheRunningVersionAsItIsReleased(t *testing.T) { + for _, tc := range []struct { + stable bool + want string + }{ + {false, "0.7.0 Preview"}, + {true, "0.7.0"}, + } { + w := newWindow(NewController(Options{ + Version: "0.7.0", Stable: tc.stable, + UpdateStatus: func() UpdateStatus { return UpdateStatus{Configured: true, UpToDate: true} }, + }), Values{}) + w.sess.page = pageUpdates + d := openWindow(t, w) + upToDate := i18n.SharedFormat(i18n.English, "desktop.settings.updates.upToDate", map[string]string{"version": tc.want}) + if !d.text(tc.want) || !d.text(upToDate) { + t.Errorf("stable = %v: the page shows %q = %v and %q = %v, want both", tc.stable, tc.want, d.text(tc.want), upToDate, d.text(upToDate)) + } + } +} + +// TestUpdateLineSaysTheStepUnderWay checks what the update row says for each state, the step +// under way first. +func TestUpdateLineSaysTheStepUnderWay(t *testing.T) { + format := func(id string, args map[string]string) string { return i18n.SharedFormat(i18n.English, id, args) } + text := func(id string) string { return i18n.Shared(i18n.English, id) } + checked := time.Date(2026, 11, 14, 9, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + status UpdateStatus + want string + }{ + {"nothing yet", UpdateStatus{Configured: true}, text("desktop.settings.updates.help")}, + {"checking", UpdateStatus{Configured: true, Checking: true, UpToDate: true}, text("desktop.settings.updates.checking")}, + {"up to date", UpdateStatus{Configured: true, UpToDate: true, LastChecked: checked}, + format("desktop.settings.updates.upToDate", map[string]string{"version": "0.7.0 Preview"})}, + {"available", UpdateStatus{Configured: true, Available: "0.7.1"}, + format("desktop.settings.updates.available", map[string]string{"version": "0.7.1"})}, + {"downloading", UpdateStatus{Configured: true, Available: "0.7.1", Downloading: true, Progress: 0.426}, + format("desktop.settings.updates.downloading", map[string]string{"version": "0.7.1", "percent": "42"})}, + {"ready", UpdateStatus{Configured: true, Available: "0.7.1", Ready: true}, + format("desktop.settings.updates.ready", map[string]string{"version": "0.7.1"})}, + {"installing", UpdateStatus{Configured: true, Available: "0.7.1", Ready: true, Installing: true}, + text("desktop.settings.updates.installing")}, + } { + if got := updateLine(i18n.English, tc.status, "0.7.0 Preview"); got != tc.want { + t.Errorf("%s: line = %q, want %q", tc.name, got, tc.want) + } + } +} diff --git a/desktop/internal/settingswindow/controller.go b/desktop/internal/settingswindow/controller.go index cbed4de78..d603e8198 100644 --- a/desktop/internal/settingswindow/controller.go +++ b/desktop/internal/settingswindow/controller.go @@ -107,7 +107,8 @@ type Values struct { // Keymap is the user's changes to the keys of the shortcut catalog; nil keeps every // default. Keymap shortcuts.Overrides - // AutoCheckUpdates has Lithe check for a newer version when it starts, at most daily. + // AutoCheckUpdates has Lithe check for a newer version 5 seconds after it starts and + // then every hour, pausing while its window is in the background. AutoCheckUpdates bool // Network is the proxy settings of the application's own HTTP traffic. Network NetworkSettings @@ -198,8 +199,10 @@ type PluginSettingsActionLoader interface { // Options configure the settings window. type Options struct { // GitHub is the account the Version Control page shows and drives; nil hides the card. - GitHub *GitHubAccount - SettingsError func(error) + GitHub *GitHubAccount + // SettingsError logs a failure of the window, with the operation that failed as the log + // names it (plugin_settings_surface, keymap_schemes, settings_about_copy, ...). + SettingsError func(operation string, err error) SettingsPages func(context.Context) ([]core.SettingsPage, error) SettingsNavigation func(context.Context, []core.SettingsNavigationItem, string) ([]core.SettingsNavigationRow, error) // Marketplace fetches the enabled source only while the plugin page is open. @@ -260,8 +263,11 @@ type Options struct { // SystemLanguage is the language the system asks for, which the choice "follow the // system" resolves to and names. SystemLanguage i18n.Language - // Version is the application's version, shown on the About card. + // Version is the application's version, shown on the About and Updates pages. Version string + // Stable says the build is a stable release, packaged from its release tag + // (buildinfo.Stable). The pages show any other build's version as a preview. + Stable bool // SetLanguage and SetTheme run on the settings window's thread and must hand their // individual changes to the settings owner without waiting for its window thread. SetLanguage func(string) @@ -326,13 +332,16 @@ type Options struct { SetLocalHistory func(LocalHistorySettings) LocalHistory LocalHistorySource // UpdateStatus reads what the updater is doing; it is safe from the window thread. - // CheckForUpdates, DownloadUpdate and InstallUpdate start the steps the Updates page - // offers, without waiting; SetAutoCheckUpdates hands a change of - // Values.AutoCheckUpdates to the owner. A nil UpdateStatus leaves the page unavailable. + // CheckForUpdates reads the feed, UpdateNow downloads and verifies the available + // version and then installs it and restarts (it installs at once a download that is + // already verified), and CancelUpdate stops a download; none of them waits. + // SetAutoCheckUpdates hands a change of Values.AutoCheckUpdates to the owner. A nil + // UpdateStatus leaves the Updates page unavailable and the About page without its + // update card. UpdateStatus func() UpdateStatus CheckForUpdates func() - DownloadUpdate func() - InstallUpdate func() + UpdateNow func() + CancelUpdate func() SetAutoCheckUpdates func(bool) // OpenURL opens an HTTPS page in the system browser. OpenURL func(string) diff --git a/desktop/internal/settingswindow/keymap_schemes_view.go b/desktop/internal/settingswindow/keymap_schemes_view.go index facbbd9f2..152307bf0 100644 --- a/desktop/internal/settingswindow/keymap_schemes_view.go +++ b/desktop/internal/settingswindow/keymap_schemes_view.go @@ -19,7 +19,9 @@ type schemeDraft struct { failure string } -func (w *window) reportEssentialsFailure(_ string, err error) { reportSettingsError(w.ctrl.opts, err) } +func (w *window) reportEssentialsFailure(operation string, err error) { + reportSettingsError(w.ctrl.opts, operation, err) +} func (w *window) reduceScheme(request core.KeymapSchemeRequest) bool { request.State = w.sess.values.KeymapSchemes diff --git a/desktop/internal/settingswindow/model.go b/desktop/internal/settingswindow/model.go index 139f5043d..3916571ff 100644 --- a/desktop/internal/settingswindow/model.go +++ b/desktop/internal/settingswindow/model.go @@ -40,6 +40,9 @@ const ( pageAI pageAIGeneration pageIDEAPI + // pageAbout is the last entry of the top level: the application itself, its version, + // its updates and where its community is, rather than a setting of a tool. + pageAbout // pageCount is how many entries there are. As a parent it means "none": the parent // of an entry on the top level. pageCount @@ -66,6 +69,7 @@ var parents = [pageCount]pageID{ pageAI: noPage, pageAIGeneration: pageAI, pageIDEAPI: pageAI, + pageAbout: noPage, } // initialPage is the page a new window opens on: the one page the new client offers. @@ -126,6 +130,8 @@ func titleOf(messages *i18n.SettingsMessages, id pageID) string { return messages.AIGeneration case pageIDEAPI: return messages.IDEAPI + case pageAbout: + return messages.About } return "" } diff --git a/desktop/internal/settingswindow/palette.go b/desktop/internal/settingswindow/palette.go index d0c1b7a06..5a71c46fa 100644 --- a/desktop/internal/settingswindow/palette.go +++ b/desktop/internal/settingswindow/palette.go @@ -114,4 +114,5 @@ var tiles = [pageCount]tile{ pageLanguages: {color: gfx.RGB24(0x2f81f7), icon: icons.FileCode}, pageTools: {color: gfx.RGB24(0x6e7681), icon: icons.Settings}, pageAI: {color: gfx.RGB24(0x8957e5), icon: icons.Sparkles}, + pageAbout: {color: gfx.RGB24(0x1b7c83), icon: icons.Info}, } diff --git a/desktop/internal/settingswindow/settings_surfaces.go b/desktop/internal/settingswindow/settings_surfaces.go index 332ddd708..1e8bd6d32 100644 --- a/desktop/internal/settingswindow/settings_surfaces.go +++ b/desktop/internal/settingswindow/settings_surfaces.go @@ -103,7 +103,7 @@ func (s *settingsSurfaces) run(parent context.Context) (surfacesResult, error) { result.err = err // log-exempt: 用户取消:快速搜索或关闭窗口取代旧请求。 if !errors.Is(err, context.Canceled) { - reportSettingsError(opts, err) + reportSettingsError(opts, pluginSettingsOperation, err) } } } @@ -148,7 +148,7 @@ func (s *settingsSurfaces) run(parent context.Context) (surfacesResult, error) { result.err = err // log-exempt: 用户取消:快速搜索或关闭窗口取代旧请求。 if !errors.Is(err, context.Canceled) { - reportSettingsError(opts, err) + reportSettingsError(opts, pluginSettingsOperation, err) } } else { for _, item := range projected { @@ -234,13 +234,13 @@ func (w *window) selectPluginForm(id string) bool { } if len(w.pluginForms) >= 64 { if !w.formLimitReported { - reportSettingsError(w.ctrl.opts, errors.New("plugin settings form capacity exceeded")) + reportSettingsError(w.ctrl.opts, pluginSettingsOperation, errors.New("plugin settings form capacity exceeded")) w.formLimitReported = true } return false } form := newPluginSettingsForm(w.ctrl.opts.PluginSettings) - form.report = func(err error) { reportSettingsError(w.ctrl.opts, err) } + form.report = func(err error) { reportSettingsError(w.ctrl.opts, pluginSettingsOperation, err) } w.formLimitReported = false w.pluginSettings = form w.pluginForms[id] = form @@ -297,9 +297,14 @@ func (w *window) contributorLabel(id string) string { return id } -func reportSettingsError(opts Options, err error) { +// pluginSettingsOperation names, in the log, a failure of the settings a plugin contributes. +const pluginSettingsOperation = "plugin_settings_surface" + +// reportSettingsError hands a failure of the settings window to the host, with the operation +// that failed as the log names it, so a failure of one page is not logged as another's. +func reportSettingsError(opts Options, operation string, err error) { if opts.SettingsError != nil { - opts.SettingsError(err) + opts.SettingsError(operation, err) } else if opts.MarketplaceError != nil { opts.MarketplaceError(err) } diff --git a/desktop/internal/settingswindow/settings_surfaces_test.go b/desktop/internal/settingswindow/settings_surfaces_test.go index 3eb45c660..3aea24c92 100644 --- a/desktop/internal/settingswindow/settings_surfaces_test.go +++ b/desktop/internal/settingswindow/settings_surfaces_test.go @@ -49,7 +49,7 @@ func TestSettingsDynamicPagesShareDraftsFilterActionsAndRefreshCleanForms(t *tes secondEnabled := true enabled := true var warnings atomic.Int32 - w := newWindow(NewController(Options{SettingsError: func(error) { warnings.Add(1) }, PluginSettings: source, Text: func() *i18n.Messages { return i18n.For(i18n.English) }, SettingsNavigation: client.SettingsNavigation, SettingsPages: func(ctx context.Context) ([]core.SettingsPage, error) { + w := newWindow(NewController(Options{SettingsError: func(string, error) { warnings.Add(1) }, PluginSettings: source, Text: func() *i18n.Messages { return i18n.For(i18n.English) }, SettingsNavigation: client.SettingsNavigation, SettingsPages: func(ctx context.Context) ([]core.SettingsPage, error) { return client.SettingsPages(ctx, []core.SettingsContribution{{Manifest: manifest, Owner: core.PluginSettingsOwner{PluginID: "dev.example.settings", Version: "1.0.0"}, Enabled: enabled}, {Manifest: otherManifest, Owner: core.PluginSettingsOwner{PluginID: "dev.example.settings-other", Version: "1.0.0"}, Enabled: secondEnabled}}) }}), Values{Theme: "dark"}) d := openWindow(t, w, func(config *app.Config) { config.Height = 1100 }) diff --git a/desktop/internal/settingswindow/settingswindow_test.go b/desktop/internal/settingswindow/settingswindow_test.go index 70089700d..3f0e5a937 100644 --- a/desktop/internal/settingswindow/settingswindow_test.go +++ b/desktop/internal/settingswindow/settingswindow_test.go @@ -123,6 +123,7 @@ func TestDefaultSidebarListsTheTopLevelWithAppearanceExpanded(t *testing.T) { {page: pageLanguages}, {page: pageTools, group: true}, {page: pageAI, group: true}, + {page: pageAbout}, } if !slices.Equal(rows, want) { t.Fatalf("rows = %v, want %v", rows, want) @@ -132,8 +133,8 @@ func TestDefaultSidebarListsTheTopLevelWithAppearanceExpanded(t *testing.T) { func TestCollapsedGroupHidesItsPages(t *testing.T) { var open [pageCount]bool rows := appendVisible(nil, english(), &open, "") - if len(rows) != 7 { - t.Fatalf("rows = %v, want the three groups and the four pages on the top level", rows) + if len(rows) != 8 { + t.Fatalf("rows = %v, want the three groups and the five pages on the top level", rows) } for _, r := range rows { if r.level != 0 || r.expanded { @@ -149,8 +150,8 @@ func TestExpandedToolsGroupListsTerminal(t *testing.T) { open := initialOpen() open[pageTools] = true rows := appendVisible(nil, english(), &open, "") - if len(rows) != 15 { - t.Fatalf("rows = %v, want the 14 of the default sidebar and the Terminal page", rows) + if len(rows) != 16 { + t.Fatalf("rows = %v, want the 15 of the default sidebar and the Terminal page", rows) } for i, r := range rows { if r.page != pageTools { @@ -266,8 +267,8 @@ func TestAppendVisibleKeepsWhatIsAlreadyInDst(t *testing.T) { open := initialOpen() dst := []row{{page: pageAI}} rows := appendVisible(dst, english(), &open, "") - if len(rows) != 15 || rows[0] != (row{page: pageAI}) { - t.Fatalf("rows = %v, want the old row followed by the 14 of the sidebar", rows) + if len(rows) != 16 || rows[0] != (row{page: pageAI}) { + t.Fatalf("rows = %v, want the old row followed by the 15 of the sidebar", rows) } } @@ -295,6 +296,7 @@ func TestBreadcrumb(t *testing.T) { {pageIDEAPI, []string{"AI", "IDE Connection"}}, {pageTools, []string{"Tools"}}, {pageTerminal, []string{"Tools", "Terminal"}}, + {pageAbout, []string{"About"}}, } for _, tc := range cases { if got := appendBreadcrumb(nil, english(), tc.page); !slices.Equal(got, tc.want) { diff --git a/desktop/internal/settingswindow/updates_view.go b/desktop/internal/settingswindow/updates_view.go index 01e27f9ee..e5b778a0b 100644 --- a/desktop/internal/settingswindow/updates_view.go +++ b/desktop/internal/settingswindow/updates_view.go @@ -8,8 +8,8 @@ import ( "github.com/vivaui/viva/ui" ) -// UpdateStatus is what the updater is doing, as the Updates page shows it. The settings -// owner keeps it and the page reads it on every paint. +// UpdateStatus is what the updater is doing, as the update card shows it. The settings +// owner keeps it and the card reads it on every paint. type UpdateStatus struct { // Configured is false for a build that has no update feed. Configured bool @@ -31,79 +31,117 @@ type UpdateStatus struct { LastChecked time.Time } -// updatesPage is the Updates page: the running version, whether Lithe checks on its own, -// and the check, download and install of a newer version. Each step is the user's: a -// check finds a version, Download fetches and verifies it, and Install restarts into it. +// updatesPage is the Updates page of Appearance & Behavior: the running version over the +// update card the About page shows too. func (w *window) updatesPage(c ui.C) { language := w.marketLanguage() - opts := &w.ctrl.opts - status := opts.UpdateStatus() w.pageScroll(c, pageUpdates, func() { w.card(c, i18n.Shared(language, "native.currentVersion"), "", func() { - ui.Text(c, opts.Version).FontSize(13).Color(w.pal.text) + ui.Text(c, w.text.App.Version(w.ctrl.opts.Version, w.ctrl.opts.Stable)).FontSize(13).Color(w.pal.text) }) - w.card(c, i18n.Shared(language, "desktop.settings.updates.title"), i18n.Shared(language, "desktop.settings.updates.help"), func() { - ui.Column(c).FillWidth().Gap(8).Children(func() { - if !status.Configured { - ui.Wrap(c, i18n.Shared(language, "desktop.settings.updates.notConfigured")).FontSize(12).Color(w.pal.muted) + w.updateCard(c) + }) +} + +// updateCard is the update controls, as Motty lays them out: a switch for the automatic +// check, and one row that says what the last check found, with Check Now beside the one +// button that updates. Update downloads and verifies the new version, then installs it +// and restarts; while it downloads, the same place cancels it. +func (w *window) updateCard(c ui.C) { + language := w.marketLanguage() + opts := &w.ctrl.opts + p := &w.pal + status := opts.UpdateStatus() + // The card needs no ⓘ: the row under it says what a check does until one has run. + w.card(c, i18n.Shared(language, "desktop.settings.updates.title"), "", func() { + if !status.Configured { + ui.Wrap(c, i18n.Shared(language, "desktop.settings.updates.notConfigured")).FontSize(12).Color(p.muted).PadY(5) + return + } + autoLabel := i18n.Shared(language, "desktop.settings.updates.auto") + w.settingsRow(c, autoLabel, i18n.Shared(language, "desktop.settings.updates.autoHelp"), func() { + auto := w.sess.values.AutoCheckUpdates + if ui.Switch(c.Part("auto"), &auto, "").Label(autoLabel).Changed() { + if w.sess.finished.Load() || w.sess.values.AutoCheckUpdates == auto { return } - auto := w.sess.values.AutoCheckUpdates - if ui.Switch(c.Part("auto"), &auto, i18n.Shared(language, "desktop.settings.updates.auto")).Changed() { - w.sess.values.AutoCheckUpdates = auto - if opts.SetAutoCheckUpdates != nil { - opts.SetAutoCheckUpdates(auto) - } - } - var line string - switch { - case status.Checking: - line = i18n.Shared(language, "desktop.settings.updates.checking") - case status.Installing: - line = i18n.Shared(language, "desktop.settings.updates.installing") - case status.Downloading: - line = i18n.SharedFormat(language, "desktop.settings.updates.downloading", map[string]string{"percent": strconv.Itoa(int(status.Progress * 100))}) - case status.Ready: - line = i18n.SharedFormat(language, "desktop.settings.updates.ready", map[string]string{"version": status.Available}) - case status.Available != "": - line = i18n.SharedFormat(language, "desktop.settings.updates.available", map[string]string{"version": status.Available}) - case status.UpToDate: - line = i18n.Shared(language, "desktop.settings.updates.upToDate") - } - if line != "" { - ui.Wrap(c, line).FontSize(12).Color(w.pal.text) - } - if status.Downloading { - ui.Progress(c, status.Progress).FillWidth() + w.sess.values.AutoCheckUpdates = auto + if opts.SetAutoCheckUpdates != nil { + opts.SetAutoCheckUpdates(auto) } - if status.Failure != "" { - ui.Wrap(c, i18n.SharedFormat(language, "desktop.settings.updates.failed", map[string]string{"reason": status.Failure})).FontSize(12).Color(w.pal.danger) - } - if !status.LastChecked.IsZero() { - ui.Text(c, i18n.SharedFormat(language, "desktop.settings.updates.lastChecked", map[string]string{"time": status.LastChecked.Local().Format("2006-01-02 15:04")})).FontSize(12).Color(w.pal.muted) - } - busy := status.Checking || status.Downloading || status.Installing - ui.Row(c).FillWidth().Gap(8).Items(ui.Center).Children(func() { - if ui.Button(c.Part("check"), i18n.Shared(language, "desktop.settings.updates.check")).Outline().Disabled(busy).Clicked() && opts.CheckForUpdates != nil { - opts.CheckForUpdates() - } - if status.Available != "" && !status.Ready { - if ui.Button(c.Part("download"), i18n.Shared(language, "desktop.settings.updates.download")).Disabled(busy).Clicked() && opts.DownloadUpdate != nil { - opts.DownloadUpdate() - } - } - if status.Ready { - if ui.Button(c.Part("install"), i18n.Shared(language, "desktop.settings.updates.install")).Disabled(busy).Clicked() && opts.InstallUpdate != nil { - opts.InstallUpdate() - } - } - if status.NotesURL != "" && opts.OpenURL != nil { - if ui.Button(c.Part("notes"), i18n.Shared(language, "desktop.settings.updates.notes")).Ghost().Clicked() { - opts.OpenURL(status.NotesURL) - } - } - }) + } + }) + ui.Row(c).FillWidth().Height(1).Shrink(0).Background(p.line).MarginSides(4, 0, 4, 0) + ui.Row(c).FillWidth().MinHeight(40).Items(ui.Center).Gap(12).Children(func() { + ui.Column(c).Grow(1).Shrink(1).MinWidth(0).Gap(2).Children(func() { + ui.Text(c, i18n.Shared(language, "desktop.settings.updates.row")).FontSize(12).Color(p.text).Ellipsis() + ui.Wrap(c, updateLine(language, status, w.text.App.Version(opts.Version, opts.Stable))).FontSize(11).Color(p.muted) }) + w.updateButtons(c, language, status) }) + if status.Downloading { + ui.Progress(c, status.Progress).FillWidth().MarginSides(2, 0, 4, 0) + } + if status.Failure != "" { + ui.Wrap(c, i18n.SharedFormat(language, "desktop.settings.updates.failed", map[string]string{"reason": status.Failure})). + FontSize(11).Color(p.danger).PadY(2) + } + if !status.LastChecked.IsZero() { + when := status.LastChecked.Local().Format("2006-01-02 15:04") + ui.Text(c, i18n.SharedFormat(language, "desktop.settings.updates.lastChecked", map[string]string{"time": when})). + FontSize(11).Color(p.subtle).PadY(2) + } + }) +} + +// updateButtons are the actions of the update row: Check Now, then Update once a version +// is available, which turns into Cancel while it downloads, then Release Notes. +func (w *window) updateButtons(c ui.C, language i18n.Language, status UpdateStatus) { + opts := &w.ctrl.opts + busy := status.Checking || status.Downloading || status.Installing + ui.Row(c).Shrink(0).Gap(8).Items(ui.Center).Children(func() { + if status.NotesURL != "" && opts.OpenURL != nil { + if ui.Button(c.Part("notes"), i18n.Shared(language, "desktop.settings.updates.notes")).Ghost().Clicked() { + opts.OpenURL(status.NotesURL) + } + } + if ui.Button(c.Part("check"), i18n.Shared(language, "desktop.settings.updates.check")).Outline().Disabled(busy).Clicked() && + opts.CheckForUpdates != nil { + opts.CheckForUpdates() + } + switch { + case status.Downloading: + if ui.Button(c.Part("cancel"), w.text.Settings.Cancel).Outline().Clicked() && opts.CancelUpdate != nil { + opts.CancelUpdate() + } + case status.Available != "": + if ui.Button(c.Part("update"), i18n.Shared(language, "desktop.settings.updates.update")).Disabled(busy).Clicked() && + opts.UpdateNow != nil { + opts.UpdateNow() + } + } }) } + +// updateLine is what the update row says under its title: the step under way, what the last +// check found, or what the check does when nothing has happened yet. current is the running +// version as the pages show it, with its preview label. +func updateLine(language i18n.Language, status UpdateStatus, current string) string { + switch { + case status.Installing: + return i18n.Shared(language, "desktop.settings.updates.installing") + case status.Downloading: + return i18n.SharedFormat(language, "desktop.settings.updates.downloading", map[string]string{ + "version": status.Available, "percent": strconv.Itoa(int(status.Progress * 100)), + }) + case status.Checking: + return i18n.Shared(language, "desktop.settings.updates.checking") + case status.Ready: + return i18n.SharedFormat(language, "desktop.settings.updates.ready", map[string]string{"version": status.Available}) + case status.Available != "": + return i18n.SharedFormat(language, "desktop.settings.updates.available", map[string]string{"version": status.Available}) + case status.UpToDate: + return i18n.SharedFormat(language, "desktop.settings.updates.upToDate", map[string]string{"version": current}) + } + return i18n.Shared(language, "desktop.settings.updates.help") +} diff --git a/desktop/internal/settingswindow/visual_capture_test.go b/desktop/internal/settingswindow/visual_capture_test.go index 85553dcb5..d55d9743f 100644 --- a/desktop/internal/settingswindow/visual_capture_test.go +++ b/desktop/internal/settingswindow/visual_capture_test.go @@ -48,13 +48,14 @@ func TestVisualSettings(t *testing.T) { {"m35-editor-tab-layout", pageEditor}, {"m25-project-open-mode", pageGeneral}, {"m29-editor-font-menu", pageFonts}, {"m38-plugin-management-menu", pagePlugins}, {"m39-proxy-mode-menu", pageNetwork}, {"m40-process-run-policy", pageProcessTermination}, {"m41-process-terminal-policy", pageProcessTermination}, {"m43-process-orphan-policy", pageProcessTermination}, + {"36-settings-about", pageAbout}, } for _, theme := range []string{"dark", "light"} { for _, scenario := range pages { t.Run(scenario.id+"-"+theme, func(t *testing.T) { opts := Options{ Text: func() *i18n.Messages { return i18n.For(language) }, Language: func() i18n.Language { return language }, - SystemLanguage: language, Version: "0.1.0", + SystemLanguage: language, Version: "0.7.0", InstalledPlugins: func() []PluginFacts { return []PluginFacts{ {ID: "dev.lithe.editor-shortcut-import", Name: "Editor Shortcut Import", Vendor: "Lithe", Version: "1.0.0", Source: PluginSourceMarketplace}, diff --git a/desktop/internal/settingswindow/window.go b/desktop/internal/settingswindow/window.go index 309dc27ab..c49d2256b 100644 --- a/desktop/internal/settingswindow/window.go +++ b/desktop/internal/settingswindow/window.go @@ -139,6 +139,10 @@ type window struct { // localHistory is the General page's unsaved local history settings and the open // project's history size (local_history_view.go). localHistory localHistoryDraft + // about is the About page's copy result, and aboutFacts the build facts it shows, read + // on its first paint (about_view.go). + about aboutState + aboutFacts *aboutFacts // keymap is the Keymap page's filter and recording. keymap keymapState @@ -230,7 +234,7 @@ func newWindow(c *Controller, current Values) *window { w.settingsNotices = make(chan string, 64) w.dynamic = newSettingsSurfaces(c.opts) w.dynamic.bind() - w.pluginSettings.report = func(err error) { reportSettingsError(c.opts, err) } + w.pluginSettings.report = func(err error) { reportSettingsError(c.opts, pluginSettingsOperation, err) } w.toolchains = newToolchainState(c.opts.Toolchains) w.stepping = newSteppingState(c.opts.SteppingAdapters, c.opts.NormalizeSteppingFilters) w.terminal = newTerminalShellState(c.opts.TerminalShells) diff --git a/desktop/internal/settingswindow/window_view.go b/desktop/internal/settingswindow/window_view.go index 05e3e343f..73a5f2b3e 100644 --- a/desktop/internal/settingswindow/window_view.go +++ b/desktop/internal/settingswindow/window_view.go @@ -183,6 +183,9 @@ func (w *window) content(c ui.C) { // Leaving the page stops wanting the fetch, which cancels it. w.market.close() } + if w.sess.page != pageAbout { + w.about = aboutState{} + } ui.Column(c).Grow(1).Shrink(1).FillHeight().MinWidth(0).Children(func() { if w.sess.page == pagePlugins && w.ctrl.opts.Marketplace != nil { w.marketplacePage(c) @@ -218,6 +221,8 @@ func (w *window) content(c ui.C) { w.processTerminationPage(c) case w.sess.page == pageIDEAPI && w.ctrl.opts.IDEAPI != nil: w.ideAPIPage(c) + case w.sess.page == pageAbout: + w.aboutPage(c) default: w.unavailablePage(c) } @@ -343,8 +348,8 @@ func (w *window) radio(c ui.C, value *string, option, label string, disabled boo return choice.Changed() } -// generalPage is the general page: the cards that edit settings, then the one that -// reports the version. +// generalPage is the general page: the cards that edit the look, the language and how +// projects open and save. The version is on the About page. func (w *window) generalPage(c ui.C) { p := &w.pal settings := &w.text.Settings @@ -420,12 +425,6 @@ func (w *window) generalPage(c ui.C) { if w.ctrl.opts.SetLocalHistory != nil { w.localHistoryCard(c) } - w.card(c, settings.About, "", func() { - ui.Column(c).FillWidth().Gap(2).Children(func() { - ui.Text(c, w.text.App.Name).FontSize(13).Weight(gfx.WeightSemibold).Color(p.text).Ellipsis() - ui.Text(c, w.text.App.Version(w.ctrl.opts.Version)).FontSize(12).Color(p.muted).Ellipsis() - }) - }) }) // A control wrote into a copy of the value: only what changed is told to the owner. if w.themePick != w.sess.values.Theme { diff --git a/desktop/internal/workbench/app.go b/desktop/internal/workbench/app.go index 189c9f87a..52403bfff 100644 --- a/desktop/internal/workbench/app.go +++ b/desktop/internal/workbench/app.go @@ -52,7 +52,6 @@ import ( // test chooses. type Kernel interface { SelectPluginFunctionProvider(context.Context, string, string, string, string, []core.PluginFunctionProviderCandidate) (string, error) - Version() string ReduceRecentProjects(ctx context.Context, entries []core.RecentProject, action core.RecentAction, capacity int) (core.RecentProjectsResult, error) GitCheckoutBranch(ctx context.Context, text string) (string, error) GitDirectoryPointer(ctx context.Context, text string) (string, error) @@ -311,8 +310,9 @@ type settings struct { // settingswindow.ProjectOpenThisWindow, ProjectOpenNewWindow, or empty to ask // (project_open_destination.go). ProjectOpenDestination string `json:"projectOpenDestination,omitempty"` - // UpdatesManual stops the daily check at startup; UpdatesCheckedAt is the last check - // that read the feed (updates.go). + // UpdatesManual turns off the automatic update check, which otherwise runs shortly + // after launch and then hourly; UpdatesCheckedAt is the last check that read the feed. + // Both are the window's (updater.store, updates.go). UpdatesManual bool `json:"updatesManual,omitempty"` UpdatesCheckedAt time.Time `json:"updatesCheckedAt,omitzero"` // Network is the proxy settings of the application's own HTTP traffic (network.go). @@ -476,8 +476,9 @@ type App struct { backdropOpen bool // shownTitle is the system title last given to the window (window_title.go). shownTitle string - // updates is the update check, download and install (updates.go). - updates updater + // updates is the update check, download and install, which every tab of the window + // shares (updates.go). + updates *updater tasks taskQueue // languages are the languages the editor knows, made when the first file is opened. languages *syntax.Registry @@ -711,8 +712,10 @@ func New(options Options) *App { a.invalidator = first.invalidator a.application, a.window, a.fullscreen = first.application, first.window, first.fullscreen a.logSink, a.logs = first.logSink, first.logs + a.updates = first.updates } else { a.sessions = newWindowSessions(a, options) + a.updates = newUpdater(a.settings) a.invalidator = windowWake{invalidator: new(app.Invalidator)} } if options.Logs != nil && options.first == nil { @@ -860,6 +863,7 @@ func (a *App) closed() { a.closeNewProject() a.closeProject() a.settingsWindow.Close() + a.stopUpdates() } // openLogs shows the log window. The window is opened from the queue drained in OnWake, @@ -950,6 +954,7 @@ func (a *App) setTheme(choice themeChoice) { func (a *App) saveSettings() { // The background is the window's, which another tab may have changed since this one loaded. a.settings.WorkbenchBackground = a.savedBackgroundState() + a.updates.store(&a.settings) if err := a.store.Save(settingsFile, a.settings); err != nil { a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("settings_save", documentLogError(err))) a.notifyError(a.text.Errors.SaveFailed(err.Error())) diff --git a/desktop/internal/workbench/filetree_menu_test.go b/desktop/internal/workbench/filetree_menu_test.go index 183665f99..9ac50de43 100644 --- a/desktop/internal/workbench/filetree_menu_test.go +++ b/desktop/internal/workbench/filetree_menu_test.go @@ -121,9 +121,17 @@ func TestProjectTreeCreatesRenamesAndDeletes(t *testing.T) { // Delete Permanently in the Delete dialog saves and closes the tab below the folder and // removes the folder from disk. + // Opening the dialog only changes state, so ask for a frame and wait until the dialog is + // in it: a frame drawn before then has no Delete Permanently button to click. h.app.openTreeRemoval(p, []string{"a"}) + h.app.invalidator.Invalidate() h.settle() - h.clickNamed(h.shared("desktop.files.deletePermanently")) + deletePermanently := h.shared("desktop.files.deletePermanently") + h.await("the Delete dialog", func() bool { + _, ok := h.findPageHit(deletePermanently) + return ok + }) + h.clickNamed(deletePermanently) h.await("the deleted folder", func() bool { _, err := os.Stat(filepath.Join(root, "a")) return os.IsNotExist(err) diff --git a/desktop/internal/workbench/logevents_test.go b/desktop/internal/workbench/logevents_test.go index 81b613281..032eebfee 100644 --- a/desktop/internal/workbench/logevents_test.go +++ b/desktop/internal/workbench/logevents_test.go @@ -67,6 +67,17 @@ func TestLogEventsMatchCatalog(t *testing.T) { {core.LevelInfo, "app.action.performed", map[string]any{"action": "workspace.background.choose", "surface": surfaceButton}}, {core.LevelInfo, "app.action.performed", map[string]any{"action": "workspace.background.remove", "surface": surfaceButton}}, {core.LevelInfo, "app.action.performed", map[string]any{"action": "workspace.background.strength", "surface": surfaceButton}}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("update_check", failure)}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("update", failure)}, + {core.LevelInfo, "app.action.performed", map[string]any{"action": "updates.check", "surface": surfaceButton}}, + {core.LevelInfo, "app.action.performed", map[string]any{"action": "updates.update", "surface": surfaceButton}}, + {core.LevelInfo, "app.action.performed", map[string]any{"action": "updates.cancel", "surface": surfaceButton}}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("settings_about_copy", failure)}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("settings_open_link", failure)}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("editor_preferences", failure)}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("keymap_schemes", failure)}, + {operationFailedLevel, operationFailedEvent, operationFailedFields("plugin_settings_surface", failure)}, + {core.LevelInfo, "app.action.performed", map[string]any{"action": "settings.openLink", "surface": surfaceButton}}, } events := make([]core.LogEvent, len(cases)) for i, c := range cases { diff --git a/desktop/internal/workbench/settings.go b/desktop/internal/workbench/settings.go index 0ea79b0e7..212d1e3ce 100644 --- a/desktop/internal/workbench/settings.go +++ b/desktop/internal/workbench/settings.go @@ -6,6 +6,7 @@ import ( "maps" "runtime" + "github.com/1lck/Lithe-IDEA/desktop/internal/buildinfo" "github.com/1lck/Lithe-IDEA/desktop/internal/core" "github.com/1lck/Lithe-IDEA/desktop/internal/fontprefs" "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" @@ -32,7 +33,7 @@ func (a *App) newSettingsWindow() *settingswindow.Controller { } return settingswindow.NewController(settingswindow.Options{ GitHub: a.githubSettings(), - SettingsError: func(err error) { (settingsToolchains{app: a}).recordFailure("plugin_settings_surface", err) }, + SettingsError: func(operation string, err error) { (settingsToolchains{app: a}).recordFailure(operation, err) }, SettingsNavigation: navigation, SettingsPages: func(ctx context.Context) ([]core.SettingsPage, error) { if a.plugins == nil { @@ -151,6 +152,7 @@ func (a *App) newSettingsWindow() *settingswindow.Controller { SystemDark: func() bool { return app.SystemAppearance() == app.AppearanceDark }, SystemLanguage: a.systemLanguage, Version: a.version, + Stable: buildinfo.Stable, SetLanguage: func(language string) { a.post(func(a *App) { a.setLanguage(language) }) }, @@ -256,17 +258,12 @@ func (a *App) newSettingsWindow() *settingswindow.Controller { } }) }, - UpdateStatus: a.updateStatus, - CheckForUpdates: func() { a.post(func(a *App) { a.checkForUpdates(true) }) }, - DownloadUpdate: func() { a.post(func(a *App) { a.downloadUpdate() }) }, - InstallUpdate: func() { a.post(func(a *App) { a.installUpdate() }) }, - SetAutoCheckUpdates: func(on bool) { - a.post(func(a *App) { - a.settings.UpdatesManual = !on - a.saveSettings() - }) - }, - OpenURL: func(address string) { _ = openURL(address) }, + UpdateStatus: a.updateStatus, + CheckForUpdates: func() { a.post(func(a *App) { a.checkForUpdates(true) }) }, + UpdateNow: func() { a.post(func(a *App) { a.updateNow() }) }, + CancelUpdate: func() { a.post(func(a *App) { a.cancelUpdate() }) }, + SetAutoCheckUpdates: func(on bool) { a.post(func(a *App) { a.setAutoCheckUpdates(on) }) }, + OpenURL: func(address string) { a.post(func(a *App) { a.openSettingsLink(address) }) }, SetNetwork: func(next settingswindow.NetworkSettings, password *string) { a.post(func(a *App) { a.setNetwork(next, password) }) }, @@ -442,3 +439,14 @@ func (a *App) openSettingsPageWith(surface string, page settingsPage, query stri } }) } + +// openSettingsLink opens a link of the settings window, such as the About page's, in the +// browser. When the system cannot, the window says so and the log records why. +func (a *App) openSettingsLink(address string) { + if err := openURL(address); err != nil { + a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("settings_open_link", err)) + a.notifyError(i18n.SharedFormat(a.language(), "desktop.terminal.openLinkFailed", map[string]string{"reason": err.Error()})) + return + } + a.performed("settings.openLink", surfaceButton) +} diff --git a/desktop/internal/workbench/updates.go b/desktop/internal/workbench/updates.go index 4dc6d15cd..0876e122d 100644 --- a/desktop/internal/workbench/updates.go +++ b/desktop/internal/workbench/updates.go @@ -3,6 +3,7 @@ package workbench import ( "context" "crypto/ed25519" + "errors" "os" "path/filepath" "runtime" @@ -16,25 +17,85 @@ import ( ) // Updates come from the signed feed a release build names (buildinfo.UpdateFeedURL): Viva's -// update package checks the Ed25519 signature, downloads the archive for this platform -// with its size and SHA-256 checked, and installs it, keeping the old application until -// the new one starts. The user starts every step from the Updates settings page; a check -// at startup, at most daily, only says that a version is available. The download lands in -// the user's cache directory, never in the installed application. +// update package checks the Ed25519 signature, downloads the archive for this platform with +// its size and SHA-256 checked, and installs it, keeping the old application until the new +// one starts. The download lands in the user's cache directory, never in the installed +// application. +// +// The checks are Motty's. With the automatic check on, the first runs updateFirstCheck after +// launch and each next one updateCheckInterval after the one before. A check that comes due +// while the window is in the background waits until the window is active again, so an idle +// Lithe does not read the feed every hour for nobody. A check nobody asked for says only that +// a version is available, once per version, in a notice: it never opens anything over what +// the user is doing, and it says nothing when it fails, which the log records. Update, in +// Settings > About and Settings > Updates, downloads and verifies the version, then installs +// it and restarts; while it downloads, Cancel stops it. -// updateCheckInterval is how often the startup check reads the feed. -const updateCheckInterval = 24 * time.Hour +const ( + // updateFirstCheck is how long after launch the first automatic check runs, late enough + // not to compete with opening the project. + updateFirstCheck = 5 * time.Second + // updateCheckInterval is the time between automatic checks. + updateCheckInterval = time.Hour + // updateCheckTimeout bounds one read of the feed. + updateCheckTimeout = time.Minute +) + +// updateSource reads the feed and downloads a release: *appupdate.Client, or a test's. +type updateSource interface { + Check(ctx context.Context) (*appupdate.Feed, error) + Download(ctx context.Context, asset appupdate.Asset, dir string, progress func(done, total int64)) (string, error) +} + +// installUpdateArchive replaces the application with a verified download and restarts it; +// a test replaces it. +var installUpdateArchive = appupdate.Install -// updater is the update state. The settings window reads it from its own thread, so the -// status is behind a lock; the steps run in goroutines and report through post. +// updateCacheDir is the user's cache directory, which downloads go under; a test replaces it. +var updateCacheDir = os.UserCacheDir + +// updater is the update state of the window, which every project tab of it shares: the +// window has one application to update and one setting for checking. The settings windows +// read the status from their own threads, so it and what the steps found are behind mu; the +// fields after them belong to the window thread. type updater struct { mu sync.Mutex status settingswindow.UpdateStatus - client *appupdate.Client + source updateSource feed *appupdate.Feed asset appupdate.Asset artifact string cancel context.CancelFunc + + // auto says Lithe checks on its own, and checkedAt is when a check last read the feed; + // both are saved with the settings. + auto bool + checkedAt time.Time + // timer is the next automatic check, and deferred says one came due while the window + // was in the background. stopped says the window closed, after which nothing is armed. + timer *time.Timer + deferred bool + stopped bool + // announced is the last version a notice told about. + announced string + // first and every are updateFirstCheck and updateCheckInterval; a test shortens them. + first, every time.Duration +} + +// newUpdater is the updater of a new window, with the stored setting and last check. +func newUpdater(stored settings) *updater { + u := &updater{auto: !stored.UpdatesManual, checkedAt: stored.UpdatesCheckedAt, first: updateFirstCheck, every: updateCheckInterval} + u.status.LastChecked = stored.UpdatesCheckedAt + return u +} + +// store writes the update setting and the last check into the settings being saved, so +// whichever tab saves, it saves the window's. +func (u *updater) store(into *settings) { + if u == nil { + return + } + into.UpdatesManual, into.UpdatesCheckedAt = !u.auto, u.checkedAt } // newUpdateClient is the feed client of this build, nil when the build names no feed. @@ -63,101 +124,269 @@ func newUpdateClient(current string) *appupdate.Client { // updateStatus is a copy of the status for the settings window. func (a *App) updateStatus() settingswindow.UpdateStatus { - u := &a.updates + u := a.updates u.mu.Lock() defer u.mu.Unlock() status := u.status - status.Configured = u.client != nil + status.Configured = u.source != nil return status } -// changeUpdate edits the status under the lock and repaints the settings window. +// changeUpdate edits the status under the lock and repaints the settings window of every +// tab, which all show it. func (a *App) changeUpdate(change func(*settingswindow.UpdateStatus)) { - u := &a.updates + u := a.updates u.mu.Lock() change(&u.status) u.mu.Unlock() - if a.settingsWindow != nil { - a.settingsWindow.Invalidate() + for _, tab := range a.everyTab() { + if tab.settingsWindow != nil { + tab.settingsWindow.Invalidate() + } + } +} + +// everyTab is every workbench of the window, open or retired, or a alone without one. +func (a *App) everyTab() []*App { + if tabs := a.sessions.everyOne(); len(tabs) > 0 { + return tabs } + return []*App{a} } -// startUpdates makes the feed client and, when the user lets Lithe check on its own and -// the last check is a day old, checks once. +// shownTab is the workbench the window shows, which a notice has to land on to be seen. +func (a *App) shownTab() *App { + if s := a.sessions; s != nil && s.active != nil { + return s.active + } + return a +} + +// startUpdates makes the feed client and, when the automatic check is on, arms the first. func (a *App) startUpdates() { - a.updates.client = newUpdateClient(a.version) - if a.updates.client == nil || !a.autoCheckUpdates() { + if client := newUpdateClient(a.version); client != nil { + a.updates.mu.Lock() + a.updates.source = client + a.updates.mu.Unlock() + } + a.scheduleUpdateCheck(a.updates.first) +} + +// autoCheckUpdates is the setting, on unless the user turned it off. +func (a *App) autoCheckUpdates() bool { return a.updates.auto } + +// setAutoCheckUpdates turns the automatic check on, arming the first one, or off. +func (a *App) setAutoCheckUpdates(on bool) { + u := a.updates + if u.auto == on { + return + } + u.auto = on + if on { + a.scheduleUpdateCheck(u.first) + } else { + a.stopUpdateCheck() + } + a.saveSettings() +} + +// scheduleUpdateCheck arms the automatic check to come due after delay. It is the only +// thing that starts a check without the user, and it stops the armed one first, so there +// is never more than one. +func (a *App) scheduleUpdateCheck(delay time.Duration) { + u := a.updates + a.stopUpdateCheck() + u.mu.Lock() + configured := u.source != nil + u.mu.Unlock() + if u.stopped || !u.auto || !configured { + return + } + var timer *time.Timer + timer = time.AfterFunc(delay, func() { + a.post(func(a *App) { + // A timer stopped after it fired still delivers: only the armed one counts. + if a.updates.timer == timer { + a.updates.timer = nil + a.updateCheckDue() + } + }) + }) + u.timer = timer +} + +// stopUpdateCheck disarms the automatic check and forgets one that is waiting. +func (a *App) stopUpdateCheck() { + u := a.updates + if u.timer != nil { + u.timer.Stop() + u.timer = nil + } + u.deferred = false +} + +// updateCheckDue runs the automatic check that came due and arms the next, or, while the +// window is in the background, keeps it for when the window is active again. +func (a *App) updateCheckDue() { + u := a.updates + if u.stopped || !u.auto { return } - if time.Since(a.settings.UpdatesCheckedAt) < updateCheckInterval { + if a.shownTab().windowInactive { + u.deferred = true return } a.checkForUpdates(false) + a.scheduleUpdateCheck(u.every) } -// autoCheckUpdates is the setting, on unless the user turned it off. -func (a *App) autoCheckUpdates() bool { return !a.settings.UpdatesManual } +// resumeUpdateCheck runs the automatic check that came due while the window was in the +// background. The window calls it when it is activated. +func (a *App) resumeUpdateCheck() { + u := a.updates + if u == nil || !u.deferred { + return + } + u.deferred = false + a.updateCheckDue() +} + +// stopUpdates is the window closing: no check is armed again, and a download stops. +func (a *App) stopUpdates() { + u := a.updates + if u == nil { + return + } + u.stopped = true + a.stopUpdateCheck() + u.mu.Lock() + cancel := u.cancel + u.mu.Unlock() + if cancel != nil { + cancel() + } +} -// checkForUpdates reads the feed. A manual check reports that nothing is newer; the -// startup check says only when a version is available. -func (a *App) checkForUpdates(manual bool) { - u := &a.updates - client := u.client - if client == nil { +// checkForUpdates reads the feed, unless a step is under way. asked says the user started +// it: only then does a failure show on the page, and only a check nobody asked for gives a +// notice. +func (a *App) checkForUpdates(asked bool) { + u := a.updates + u.mu.Lock() + source := u.source + busy := u.status.Checking || u.status.Downloading || u.status.Installing + u.mu.Unlock() + if source == nil || busy { return } - a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Checking, s.Failure, s.UpToDate = true, "", false }) + if asked { + a.performed("updates.check", surfaceButton) + } + a.changeUpdate(func(s *settingswindow.UpdateStatus) { + s.Checking = true + if asked { + s.Failure = "" + } + }) go func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), updateCheckTimeout) defer cancel() - feed, err := client.Check(ctx) + feed, err := source.Check(ctx) var asset appupdate.Asset if err == nil && feed != nil { asset, err = feed.Asset(runtime.GOOS, runtime.GOARCH) } - a.post(func(a *App) { - u.mu.Lock() - u.feed, u.asset = feed, asset - u.mu.Unlock() - a.changeUpdate(func(s *settingswindow.UpdateStatus) { - s.Checking = false - if err != nil { - s.Failure = err.Error() - return - } - s.LastChecked = time.Now() - s.Available, s.NotesURL, s.UpToDate = "", "", feed == nil - if feed != nil { - s.Available, s.NotesURL = feed.Latest.Version, feed.Latest.NotesURL - } - }) - if err != nil { - a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("update_check", err)) - return - } - a.settings.UpdatesCheckedAt = time.Now() - a.saveSettings() - if feed != nil && !manual { - a.notifyInfo(a.text.Updates.Available(feed.Latest.Version)) + a.post(func(a *App) { a.updateChecked(asked, feed, asset, err) }) + }() +} + +// updateChecked takes in what a check found: nothing newer, or a version to update to. +// A download of another version is forgotten, since Update would install the wrong one. +func (a *App) updateChecked(asked bool, feed *appupdate.Feed, asset appupdate.Asset, err error) { + u := a.updates + if err != nil { + a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("update_check", err)) + a.changeUpdate(func(s *settingswindow.UpdateStatus) { + s.Checking = false + if asked { + s.Failure = err.Error() } }) - }() - a.performed("updates.check", surfaceButton) + return + } + version := "" + if feed != nil { + version = feed.Latest.Version + } + now := time.Now() + u.checkedAt = now + a.changeUpdate(func(s *settingswindow.UpdateStatus) { + // changeUpdate holds u.mu, which guards what the check found too. + if u.feed == nil || feed == nil || u.feed.Latest.Version != version { + u.artifact = "" + } + u.feed, u.asset = feed, asset + s.Checking, s.Failure, s.LastChecked = false, "", now + s.Available, s.NotesURL, s.UpToDate, s.Ready = version, "", feed == nil, u.artifact != "" + if feed != nil { + s.NotesURL = feed.Latest.NotesURL + } + }) + a.saveSettings() + if version == "" || version == u.announced { + return + } + // A version the user just saw on the page needs no notice later. + u.announced = version + if !asked { + a.shownTab().notifyInfo(a.text.Updates.Available(version)) + } +} + +// updateNow is Update: it installs the verified download, or downloads the available +// version first and installs it once verified. +func (a *App) updateNow() { + u := a.updates + u.mu.Lock() + busy := u.status.Checking || u.status.Downloading || u.status.Installing + ready, available := u.artifact != "", u.feed != nil + u.mu.Unlock() + if busy || !available { + return + } + a.performed("updates.update", surfaceButton) + if ready { + a.installUpdate() + return + } + a.downloadUpdate() +} + +// cancelUpdate stops the download under way. +func (a *App) cancelUpdate() { + u := a.updates + u.mu.Lock() + cancel := u.cancel + u.mu.Unlock() + if cancel != nil { + cancel() + a.performed("updates.cancel", surfaceButton) + } } // downloadUpdate fetches and verifies the archive of the available version into the -// user's cache directory. +// user's cache directory, then installs it. func (a *App) downloadUpdate() { - u := &a.updates + u := a.updates u.mu.Lock() - client, feed, asset := u.client, u.feed, u.asset + source, feed, asset := u.source, u.feed, u.asset u.mu.Unlock() - if client == nil || feed == nil { + if source == nil || feed == nil { return } - cache, err := os.UserCacheDir() + cache, err := updateCacheDir() if err != nil { - a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Failure = err.Error() }) + a.updateFailed(err) return } dir := filepath.Join(cache, "Lithe", "Updates", feed.Latest.Version) @@ -168,63 +397,84 @@ func (a *App) downloadUpdate() { a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Downloading, s.Progress, s.Failure = true, 0, "" }) go func() { defer cancel() - if err := os.MkdirAll(dir, 0o700); err != nil { - a.post(func(a *App) { a.updateFailed(err) }) - return + var artifact string + err := os.MkdirAll(dir, 0o700) + if err == nil { + // Progress arrives on the download goroutine: it repaints the settings window + // through post, once per whole percent. + shown := -1 + artifact, err = source.Download(ctx, asset, dir, func(done, total int64) { + if total <= 0 || int(done*100/total) == shown { + return + } + shown = int(done * 100 / total) + progress := float32(done) / float32(total) + a.post(func(a *App) { a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Progress = progress }) }) + }) } - // Progress arrives on the download goroutine: it repaints the settings window - // through post, once per whole percent. - shown := -1 - artifact, err := client.Download(ctx, asset, dir, func(done, total int64) { - if total <= 0 || int(done*100/total) == shown { - return - } - shown = int(done * 100 / total) - progress := float32(done) / float32(total) - a.post(func(a *App) { a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Progress = progress }) }) - }) - a.post(func(a *App) { - if err != nil { - a.updateFailed(err) - return - } - u.mu.Lock() - u.artifact = artifact - u.mu.Unlock() - a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Downloading, s.Ready, s.Progress = false, true, 1 }) - }) + a.post(func(a *App) { a.updateDownloaded(feed, artifact, err) }) }() - a.performed("updates.download", surfaceButton) } -// installUpdate closes the project the way quitting does, saving or asking about unsaved -// files, then hands the verified archive to the installer, which restarts into the new -// version. If the installer refuses, nothing was changed and the window stays. +// updateDownloaded takes in the end of a download: a cancel puts the row back as it was, a +// verified archive is installed at once. +func (a *App) updateDownloaded(feed *appupdate.Feed, artifact string, err error) { + u := a.updates + u.mu.Lock() + u.cancel = nil + current := u.feed == feed + if err == nil && current { + u.artifact = artifact + } + u.mu.Unlock() + switch { + case errors.Is(err, context.Canceled): + // log-exempt: 用户取消下载或窗口关闭,不是失败。 + a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Downloading, s.Progress = false, 0 }) + case err != nil: + a.updateFailed(err) + case !current: + // A check replaced the version while it downloaded: Update starts again from it. + a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Downloading, s.Progress = false, 0 }) + default: + a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Downloading, s.Ready, s.Progress = false, true, 1 }) + a.installUpdate() + } +} + +// installUpdate closes every project tab the way quitting does, saving or asking about +// unsaved files, then hands the verified archive to the installer, which restarts into the +// new version. If a tab is not let go, nothing happens and Update installs on the next try; +// if the installer refuses, nothing was changed and the window stays. func (a *App) installUpdate() { - u := &a.updates + u := a.updates u.mu.Lock() artifact, asset, feed := u.artifact, u.asset, u.feed u.mu.Unlock() if artifact == "" || feed == nil || a.application == nil { return } - a.performed("updates.install", surfaceButton) - a.requestCloseProject(func() { + install := func() { a.changeUpdate(func(s *settingswindow.UpdateStatus) { s.Installing, s.Failure = true, "" }) options := appupdate.InstallOptions{Artifact: artifact, Asset: asset, Version: feed.Latest.Version, App: buildinfo.UpdateApp} if runtime.GOOS != "darwin" { options.App = "" } - if err := appupdate.Install(options, a.application.Quit); err != nil { + if err := installUpdateArchive(options, a.application.Quit); err != nil { a.updateFailed(err) } - }) + } + if a.sessions == nil { + a.requestCloseProject(install) + return + } + a.sessions.closeAll(install) } -// updateFailed shows why a step failed and logs it. +// updateFailed shows why a step the user started failed and logs it. func (a *App) updateFailed(err error) { a.changeUpdate(func(s *settingswindow.UpdateStatus) { - s.Downloading, s.Installing, s.Checking = false, false, false + s.Downloading, s.Installing, s.Checking, s.Progress = false, false, false, 0 s.Failure = err.Error() }) a.emit(operationFailedLevel, operationFailedEvent, operationFailedFields("update", err)) diff --git a/desktop/internal/workbench/updates_test.go b/desktop/internal/workbench/updates_test.go new file mode 100644 index 000000000..b2bd425a4 --- /dev/null +++ b/desktop/internal/workbench/updates_test.go @@ -0,0 +1,189 @@ +package workbench + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/1lck/Lithe-IDEA/desktop/internal/buildinfo" + "github.com/1lck/Lithe-IDEA/desktop/internal/core" + appupdate "github.com/vivaui/viva/update" +) + +// fakeUpdateSource answers each check with the feed or the error the test set, and holds a +// download, after reporting a quarter of it, until the download is cancelled. +type fakeUpdateSource struct { + mu sync.Mutex + feed *appupdate.Feed + err error + checks int +} + +func (f *fakeUpdateSource) Check(context.Context) (*appupdate.Feed, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.checks++ + return f.feed, f.err +} + +func (f *fakeUpdateSource) Download(ctx context.Context, _ appupdate.Asset, _ string, progress func(done, total int64)) (string, error) { + progress(1, 4) + <-ctx.Done() + return "", ctx.Err() +} + +func (f *fakeUpdateSource) checkCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.checks +} + +// updateFeed is a feed offering version with an archive for this platform. +func updateFeed(version string) *appupdate.Feed { + return &appupdate.Feed{App: buildinfo.UpdateApp, Latest: appupdate.Release{Version: version, Assets: []appupdate.Asset{{ + OS: runtime.GOOS, Arch: runtime.GOARCH, Format: appupdate.FormatZIP, + URL: "https://example.invalid/lithe.zip", Size: 4, SHA256: strings.Repeat("0", 64), + }}}} +} + +// newUpdateHarness is a window whose updater reads source. The updater is stopped with the +// test, so no check stays armed and a held download ends. +func newUpdateHarness(t *testing.T, source *fakeUpdateSource) *harness { + t.Helper() + h := newHarness(t, &testKernel{Client: core.NewClient()}) + u := h.app.updates + u.mu.Lock() + u.source = source + u.mu.Unlock() + t.Cleanup(h.app.stopUpdates) + return h +} + +// savedSettings reads the settings file afresh, so a field the file leaves out reads as unset. +func savedSettings(t *testing.T, h *harness) settings { + t.Helper() + var saved settings + if _, err := h.store.Load(settingsFile, &saved); err != nil { + t.Fatalf("read the saved settings: %v", err) + } + return saved +} + +// noticeCount is how often the window said message. +func noticeCount(a *App, message string) int { + for _, entry := range a.notifications.list { + if entry.text == message { + return entry.count + } + } + return 0 +} + +// TestAutomaticUpdateCheckWaitsForTheWindowAndAnnouncesOnce walks Motty's automatic check: a +// check that comes due while the window is in the background waits, runs when the window is +// active again, says in a notice that a version is available, saves when it read the feed and +// arms the next check; the next check finding the same version says nothing again. +func TestAutomaticUpdateCheckWaitsForTheWindowAndAnnouncesOnce(t *testing.T) { + source := &fakeUpdateSource{feed: updateFeed("0.8.0")} + h := newUpdateHarness(t, source) + a := h.app + + a.windowInactive = true + a.updateCheckDue() + if source.checkCount() != 0 || !a.updates.deferred { + t.Fatalf("a check due in the background ran %d times, deferred = %v; want it kept for later", source.checkCount(), a.updates.deferred) + } + + a.windowInactive = false + a.resumeUpdateCheck() + h.await("the deferred check", func() bool { return a.updateStatus().Available == "0.8.0" }) + notice := a.text.Updates.Available("0.8.0") + if n := noticeCount(a, notice); n != 1 { + t.Fatalf("the notice came %d times, want once", n) + } + if a.updates.timer == nil { + t.Fatal("no next check is armed after the automatic check") + } + if saved := savedSettings(t, h); saved.UpdatesCheckedAt.IsZero() || saved.UpdatesManual { + t.Fatalf("saved checked at %v, manual = %v; want the time of the check with the automatic check on", saved.UpdatesCheckedAt, saved.UpdatesManual) + } + + a.updateCheckDue() + h.await("the next check", func() bool { return source.checkCount() == 2 && !a.updateStatus().Checking }) + if n := noticeCount(a, notice); n != 1 { + t.Fatalf("the same version was announced %d times, want once", n) + } +} + +// TestUpdateCheckFailureShowsOnlyWhenAsked fails the feed: a check nobody asked for says +// nothing on the page, and Check Now says why it failed. +func TestUpdateCheckFailureShowsOnlyWhenAsked(t *testing.T) { + source := &fakeUpdateSource{err: errors.New("feed unreachable")} + h := newUpdateHarness(t, source) + a := h.app + + a.updateCheckDue() + h.await("the automatic check", func() bool { return source.checkCount() == 1 && !a.updateStatus().Checking }) + if status := a.updateStatus(); status.Failure != "" { + t.Fatalf("the automatic check showed %q, want nothing", status.Failure) + } + + a.checkForUpdates(true) + h.await("the asked check", func() bool { return a.updateStatus().Failure == "feed unreachable" }) +} + +// TestCancelledUpdateDownloadPutsTheRowBack starts Update, which downloads into the user's +// cache directory with its progress shown, and cancels it: the row offers the version again +// and says nothing failed. +func TestCancelledUpdateDownloadPutsTheRowBack(t *testing.T) { + cache := t.TempDir() + saved := updateCacheDir + updateCacheDir = func() (string, error) { return cache, nil } + t.Cleanup(func() { updateCacheDir = saved }) + source := &fakeUpdateSource{feed: updateFeed("0.8.0")} + h := newUpdateHarness(t, source) + a := h.app + + a.checkForUpdates(true) + h.await("the available version", func() bool { return a.updateStatus().Available == "0.8.0" }) + a.updateNow() + h.await("the download's progress", func() bool { + status := a.updateStatus() + return status.Downloading && status.Progress == 0.25 + }) + if info, err := os.Stat(filepath.Join(cache, "Lithe", "Updates", "0.8.0")); err != nil || !info.IsDir() { + t.Fatalf("the download directory is %v (%v), want it in the cache directory", info, err) + } + + a.cancelUpdate() + h.await("the cancelled download", func() bool { return !a.updateStatus().Downloading }) + if status := a.updateStatus(); status.Failure != "" || status.Ready || status.Progress != 0 || status.Available != "0.8.0" { + t.Fatalf("after Cancel the status is %+v, want the version offered again and no failure", status) + } +} + +// TestTurningOffTheAutomaticCheckDisarmsAndSavesIt turns the automatic check off, which +// disarms the next check and is saved, and on again, which arms it. +func TestTurningOffTheAutomaticCheckDisarmsAndSavesIt(t *testing.T) { + h := newUpdateHarness(t, &fakeUpdateSource{}) + a := h.app + a.scheduleUpdateCheck(a.updates.every) + if a.updates.timer == nil { + t.Fatal("no check is armed with the automatic check on") + } + + a.setAutoCheckUpdates(false) + if manual := savedSettings(t, h).UpdatesManual; !manual || a.updates.timer != nil || a.autoCheckUpdates() { + t.Fatalf("after turning it off: saved manual = %v, armed = %v; want it saved off and nothing armed", manual, a.updates.timer != nil) + } + + a.setAutoCheckUpdates(true) + if manual := savedSettings(t, h).UpdatesManual; manual || a.updates.timer == nil { + t.Fatalf("after turning it on: saved manual = %v, armed = %v; want it saved on and the first check armed", manual, a.updates.timer != nil) + } +} diff --git a/desktop/internal/workbench/welcome_test.go b/desktop/internal/workbench/welcome_test.go index 6689b9061..149ae1929 100644 --- a/desktop/internal/workbench/welcome_test.go +++ b/desktop/internal/workbench/welcome_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/1lck/Lithe-IDEA/desktop/internal/buildinfo" "github.com/1lck/Lithe-IDEA/desktop/internal/core" "github.com/vivaui/viva/app" "github.com/vivaui/viva/gfx" @@ -18,6 +19,8 @@ func TestWelcomeTitleAndVersionMatchWindowChrome(t *testing.T) { if err := h.driver.Resize(900, 600); err != nil { t.Fatal(err) } + // A test build is not a release, so the version carries the preview label. + version := h.app.text.App.Version("0.6.7", buildinfo.Stable) var titleFound, versionFound bool for _, label := range h.driver.Snapshot().Labels { switch label.Value { @@ -26,7 +29,7 @@ func TestWelcomeTitleAndVersionMatchWindowChrome(t *testing.T) { if runtime.GOOS == "darwin" && (label.Bounds.X < 80 || label.Bounds.X > 120) { t.Fatalf("welcome title overlaps the system controls or is centered: %v", label.Bounds) } - case "0.6.7": + case version: versionFound = true case "版本 0.6.7": t.Fatal("welcome version includes an extra label absent from the native sidebar") diff --git a/desktop/internal/workbench/welcome_view.go b/desktop/internal/workbench/welcome_view.go index 919fea3ad..712b31883 100644 --- a/desktop/internal/workbench/welcome_view.go +++ b/desktop/internal/workbench/welcome_view.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/1lck/Lithe-IDEA/desktop/internal/appicon" + "github.com/1lck/Lithe-IDEA/desktop/internal/buildinfo" "github.com/1lck/Lithe-IDEA/desktop/internal/core" "github.com/1lck/Lithe-IDEA/desktop/internal/i18n" "github.com/1lck/Lithe-IDEA/desktop/internal/icons" @@ -67,7 +68,7 @@ func (a *App) welcomeSidebar(c ui.C) { ui.Image(c, appicon.Welcome()).Size(28, 28).Shrink(0) ui.Column(c).Children(func() { ui.Text(c, a.text.App.Name).FontSize(13) - ui.Text(c, a.version).Muted().FontSize(11) + ui.Text(c, a.text.App.Version(a.version, buildinfo.Stable)).Muted().FontSize(11) }) }) ui.Column(c).PadX(14).Children(func() { diff --git a/desktop/internal/workbench/window_sessions.go b/desktop/internal/workbench/window_sessions.go index f8d37e467..0db44612e 100644 --- a/desktop/internal/workbench/window_sessions.go +++ b/desktop/internal/workbench/window_sessions.go @@ -127,6 +127,11 @@ func (s *windowSessions) config() app.Config { a.applySelectionFocus() a.invalidator.Invalidate() } + // An automatic update check that came due while the window was in the background + // runs now. + if s.active != nil { + s.active.resumeUpdateCheck() + } } window.OnFullscreen = func(fullscreen bool) { for _, a := range s.list { diff --git a/docs/viva-migration.md b/docs/viva-migration.md index 1480ea4ff..ff4f9ca94 100644 --- a/docs/viva-migration.md +++ b/docs/viva-migration.md @@ -574,7 +574,7 @@ MCP 管理随 Agent 对话失去消费者后,本轮整块删除,后续重新 | 应用名 / 版本 | `CFBundleName` Lithe,版本 0.1.0(Info.plist),发布时覆盖 | `productName` Lithe,0.3.0 | `name = "Lithe Desktop"`,`version = "0.7.0"` | 否(产品名不同,版本线独立) | | macOS 更新 | Sparkle:`SUFeedURL` 与 `SUPublicEDKey` 在构建时由 `configure-sparkle-app.sh` 写入。Stable 源 `https://github.com/<仓库>/releases/latest/download/appcast-.xml`;AutoBuild 源 `https://github.com/<仓库>/releases/download/AutoBuild/appcast-preview-.xml`;公钥来自仓库变量 `SPARKLE_PUBLIC_KEY`(值未在仓库内,未找到);`SURequireSignedFeed`、`SUVerifyUpdateBeforeExtraction` 为 true;说明见 `docs/architecture/macos-updates.md` | — | `viva.toml` 与 `scripts/desktop/package-desktop.sh` 中未找到 Sparkle、appcast、feed、公钥任何配置 | 否。新客户端自建更新通道(新的 appcast 与签名公钥),不衔接旧 Sparkle 增量链;尚未实现(`updates-check-download` 为未开始) | | Windows 安装与更新 | — | Tauri 打包 `nsis`、`msi`(`tauri.conf.json` `bundle.targets`);Tauri updater 插件,`tauri.conf.json` 中 `pubkey` 为空串、`endpoints` 为空;稳定版发布工作流 `release-windows.yml` 用 secret `TAURI_SIGNING_PRIVATE_KEY` 与变量 `TAURI_UPDATER_PUBLIC_KEY` 签名,产出 `Lithe-<版本>-windows-x64-updater.exe` | `viva.toml [windows]`:`scope = "user"`、`publisher = "Lithe contributors"`;`package-desktop.sh` Windows 默认格式 `nsis,zip`;更新机制未找到 | 部分。同为 NSIS;没有 MSI、更新器与签名公钥配置 | -| 发布工作流 | `release-macos.yml`、`release-preview-macos.yml` | `release-windows.yml`、`release-preview-windows.yml` | `.github/workflows/desktop-release.yml`,标签 `desktop-v*.*.*`,作为 prerelease 发布,避免旧客户端的更新器读到;`.github/workflows/desktop-autobuild.yml` 每天从 `preview` 构建并滚动发布到 prerelease `desktop-AutoBuild`(macOS arm64、Windows x64、Linux x64/arm64 的 deb 与 tar.gz,不跑测试),标签与旧客户端的 `AutoBuild` 分开 | 独立 | +| 发布工作流 | `release-macos.yml`、`release-preview-macos.yml` | `release-windows.yml`、`release-preview-windows.yml` | `.github/workflows/desktop-release.yml`,标签 `desktop-v*.*.*`,作为 prerelease 发布,避免旧客户端的更新器读到;只有它用 `package-desktop.sh --stable`(`lithe_stable` 构建标签)打包,应用内显示不带“预览版”的版本号,其他构建都显示“<版本> 预览版”;`.github/workflows/desktop-autobuild.yml` 每天从 `preview` 构建并滚动发布到 prerelease `desktop-AutoBuild`(macOS arm64、Windows x64、Linux x64/arm64 的 deb 与 tar.gz,不跑测试),标签与旧客户端的 `AutoBuild` 分开 | 独立 | | 运行时只读边界 | 发行 bundle 运行期不可写(AGENTS.md) | 安装目录只读 | `package-desktop.sh` 注释声明运行时写入不落在 app 或安装目录;`runtime-package-integrity` 仅 macOS 夹具验证 | 一致于规则;Windows/最终包验证未完成 | diff --git a/scripts/ci/desktop-release/package-release.sh b/scripts/ci/desktop-release/package-release.sh index dcfd8a3d0..00a88be14 100755 --- a/scripts/ci/desktop-release/package-release.sh +++ b/scripts/ci/desktop-release/package-release.sh @@ -42,6 +42,15 @@ if [[ "$VERSION" != "$MANIFEST_VERSION" ]]; then printf '%s\n' "Requested version $VERSION does not match desktop/viva.toml version $MANIFEST_VERSION." >&2 exit 1 fi +# The packages are the stable release (package-desktop.sh --stable), which shows the plain version: +# the release publishes them under desktop-v, the tag of the commit built. A run started by +# pushing that tag must find it on HEAD; a manual run creates the tag on this commit when it +# publishes, or publishes nothing. +RELEASE_TAG="desktop-v$VERSION" +if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]] && ! git -C "$ROOT_DIR" tag --points-at HEAD | grep -Fqx -- "$RELEASE_TAG"; then + printf '%s\n' "The checked-out commit does not carry $RELEASE_TAG, the tag this release publishes." >&2 + exit 1 +fi SIGN_ARGS=() SIGNED=0 @@ -94,6 +103,15 @@ contains_version_line() { strings -a "$1" | grep -Fqx -- "$EXPECTED_VERSION_LINE" } +# is_stable_program says the program was built with the lithe_stable tag, so it shows the plain +# version rather than a preview; go version -m reads the build settings Go records in every binary. +is_stable_program() { + go version -m "$1" | awk '$1 == "build" && $2 ~ /^-tags=/ { + n = split(substr($2, 7), tags, ",") + for (i = 1; i <= n; i++) if (tags[i] == "lithe_stable") found = 1 + } END { exit !found }' +} + write_checksum() { local artifact_name="$1" ( @@ -114,7 +132,7 @@ write_checksum() { case "$GOOS" in darwin) - APPLE_ARGS=(--arch "$ARCH" --format 'app,zip,dmg' --out "$OUT_DIR") + APPLE_ARGS=(--arch "$ARCH" --format 'app,zip,dmg' --out "$OUT_DIR" --stable) APPLE_ARGS+=("${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"}") "$ROOT_DIR/scripts/desktop/package-desktop.sh" "${APPLE_ARGS[@]+"${APPLE_ARGS[@]}"}" @@ -133,6 +151,10 @@ case "$GOOS" in printf '%s\n' "The program does not carry $EXPECTED_VERSION_LINE from viva.toml." >&2 exit 1 fi + if ! is_stable_program "$PROGRAM"; then + printf '%s\n' 'The program was not built as the stable release (no lithe_stable build tag).' >&2 + exit 1 + fi if [[ "$SIGNED" == 1 ]]; then codesign --verify --deep --strict "$APP" fi @@ -148,7 +170,7 @@ case "$GOOS" in ;; windows) STEM="lithe-$VERSION-windows-$ARCH" - "$ROOT_DIR/scripts/desktop/package-desktop.sh" --arch "$ARCH" --format nsis,zip --out "$OUT_DIR" \ + "$ROOT_DIR/scripts/desktop/package-desktop.sh" --arch "$ARCH" --format nsis,zip --out "$OUT_DIR" --stable \ "${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"}" INSTALLER="$OUT_DIR/$STEM-setup.exe" @@ -169,6 +191,10 @@ case "$GOOS" in printf '%s\n' "The extracted Windows program does not carry $EXPECTED_VERSION_LINE." >&2 exit 1 fi + if ! is_stable_program "$EXTRACTED_PROGRAM"; then + printf '%s\n' 'The Windows program was not built as the stable release (no lithe_stable build tag).' >&2 + exit 1 + fi if [[ "$SIGNED" == 1 ]]; then WINDOWS_PROGRAM_PATH="$(native_path "$EXTRACTED_PROGRAM")" WINDOWS_INSTALLER_PATH="$(native_path "$INSTALLER")" @@ -199,7 +225,7 @@ case "$GOOS" in fi STEM="lithe-$VERSION-linux-$ARCH" DEB_NAME="lithe_${VERSION}_${DEB_ARCH}.deb" - "$ROOT_DIR/scripts/desktop/package-desktop.sh" --arch "$ARCH" --format tar.gz,deb --out "$OUT_DIR" + "$ROOT_DIR/scripts/desktop/package-desktop.sh" --arch "$ARCH" --format tar.gz,deb --out "$OUT_DIR" --stable TARBALL="$OUT_DIR/$STEM.tar.gz" DEB="$OUT_DIR/$DEB_NAME" @@ -229,6 +255,10 @@ case "$GOOS" in printf '%s\n' "The extracted Linux program does not carry $EXPECTED_VERSION_LINE." >&2 exit 1 fi + if ! is_stable_program "$EXTRACTED_PROGRAM"; then + printf '%s\n' 'The Linux program was not built as the stable release (no lithe_stable build tag).' >&2 + exit 1 + fi DEB_VERSION="$(dpkg-deb -f "$DEB" Version)" DEB_ARCHITECTURE="$(dpkg-deb -f "$DEB" Architecture)" if [[ "$DEB_VERSION" != "$VERSION" ]]; then diff --git a/scripts/desktop/generate-desktop-icons.sh b/scripts/desktop/generate-desktop-icons.sh index 73f5b3c8a..263b9e894 100755 --- a/scripts/desktop/generate-desktop-icons.sh +++ b/scripts/desktop/generate-desktop-icons.sh @@ -30,7 +30,7 @@ ICONS=( arrow-up-down arrow-down-to-line circle-x loader-circle wrap-text info copy square wrench eraser undo-2 pencil arrow-up arrow-down git-compare fold-vertical unfold-vertical archive columns-2 pause redo-dot arrow-down-to-dot arrow-up-from-dot circle-dot circle-slash braces - variable rotate-ccw + variable rotate-ccw github monitor external-link message-circle messages-square ) RAW_URL="https://raw.githubusercontent.com/lucide-icons/lucide/$LUCIDE_VERSION" diff --git a/scripts/desktop/package-desktop.sh b/scripts/desktop/package-desktop.sh index c0e5513ab..294cc7d40 100755 --- a/scripts/desktop/package-desktop.sh +++ b/scripts/desktop/package-desktop.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash # Packages the Viva desktop client for the host OS: # -# Usage: scripts/desktop/package-desktop.sh [--arch arm64|amd64] [--format LIST] [--out DIR] [--no-sign] +# Usage: scripts/desktop/package-desktop.sh [--arch arm64|amd64] [--format LIST] [--out DIR] [--no-sign] [--stable] +# +# --stable packages the stable release, which shows the plain version; without it the client shows +# the version as a preview. Only the Desktop Release passes it (scripts/ci/desktop-release). # # Viva builds the program from desktop/viva.toml, embeds that manifest and applies Windows # resources and signing. It must receive desktop-go.sh's cgo environment so the packaged client @@ -38,6 +41,7 @@ ARCH="${GOARCH:-$(go env GOARCH)}" FORMATS="" OUT_DIR="" SIGN_ARGS=() +STABLE=0 while [[ $# -gt 0 ]]; do case "$1" in --arch) @@ -65,8 +69,9 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --no-sign) SIGN_ARGS=(--no-sign); shift ;; + --stable) STABLE=1; shift ;; *) - printf '%s\n' "Usage: $0 [--arch arm64|amd64] [--format LIST] [--out DIR] [--no-sign]" >&2 + printf '%s\n' "Usage: $0 [--arch arm64|amd64] [--format LIST] [--out DIR] [--no-sign] [--stable]" >&2 exit 2 ;; esac @@ -192,9 +197,19 @@ trap 'rm -f -- "$EMBED_FILE"' EXIT # The macOS client's fonts (desktop/internal/bundledfont/fonts, tracked in Git) are compiled into the # executable under the lithe_bundled_fonts build tag, so the app bundle gets no font files and the # client never reads its own installation for them. Other platforms ignore the tag. +BUILD_TAGS="lithe_bundled_helper,lithe_bundled_fonts" +# --stable, which only the Desktop Release passes for the commit of its desktop-v tag, adds +# the lithe_stable build tag: the client then shows the plain version. Any other package, the daily +# AutoBuild or a local one, shows the version as a preview. +if [[ "$STABLE" == 1 ]]; then + BUILD_TAGS="$BUILD_TAGS,lithe_stable" + printf '%s\n' "Packaging the stable release $VERSION." +else + printf '%s\n' "Packaging a preview of $VERSION." +fi GOARCH="$ARCH" LITHE_DESKTOP_CORE_PROFILE=release \ "$ROOT_DIR/scripts/desktop/desktop-go.sh" --exec "$VIVA_BIN" package \ --target "$GOOS" --arch "$ARCH" --format "$FORMATS" -o "$VIVA_OUT" \ - -tags lithe_bundled_helper,lithe_bundled_fonts \ + -tags "$BUILD_TAGS" \ "${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"}" printf '%s\n' "$OUT_DIR" diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 690bad609..407b9aed0 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -737,9 +737,8 @@ "desktop.aiCommit.responseTooLarge": "The AI provider response is larger than 2 MiB.", "desktop.aiCommit.stale": "The staged changes changed while the message was generated. Generate it again.", "desktop.aiCommit.timeout": "The AI provider did not answer within 45 seconds.", - "desktop.app.coreVersion": "Core {version}", "desktop.app.devBadge": "Dev", - "desktop.app.version": "Version {version}", + "desktop.app.previewVersion": "{version} Preview", "desktop.branch.alreadyOn": "Already on '{name}'", "desktop.branch.branchName": "Branch name", "desktop.branch.checkedOut": "Checked out '{name}'", @@ -1379,6 +1378,27 @@ "desktop.semantics.renameTitle": "Rename Symbol", "desktop.semantics.targetUnavailable": "The target is not a local file and cannot be opened yet.", "desktop.settings.about": "About", + "desktop.settings.about.tagline": "A low-memory IntelliJ IDEA alternative for Java development and AI collaboration", + "desktop.settings.about.copy": "Copy", + "desktop.settings.about.copied": "Copied", + "desktop.settings.about.copyHelp": "Copies the version, platform, build and runtime, ready to paste into an issue.", + "desktop.settings.about.copyFailed": "Couldn't copy: {reason}", + "desktop.settings.about.info": "Version Info", + "desktop.settings.about.version": "Version", + "desktop.settings.about.platform": "Platform", + "desktop.settings.about.build": "Build", + "desktop.settings.about.buildUnknown": "Not recorded", + "desktop.settings.about.runtime": "Runtime", + "desktop.settings.about.community": "Community", + "desktop.settings.about.qq": "QQ Group", + "desktop.settings.about.qqHelp": "Group {number}: questions, feedback and chat.", + "desktop.settings.about.wechat": "WeChat Group", + "desktop.settings.about.wechatHelp": "Scan the QR code on the Feishu page to join.", + "desktop.settings.about.project": "Project", + "desktop.settings.about.repo": "Source Code", + "desktop.settings.about.repoHelp": "Source, releases and discussions on GitHub.", + "desktop.settings.about.issues": "Report an Issue", + "desktop.settings.about.issuesHelp": "Report a bug or suggest a feature on GitHub.", "desktop.settings.aiGeneration.help": "The provider and options for commit message generation and inline edits. These are Lithe's own preferences: they affect only this app and do not change the Codex or Claude configuration.", "desktop.settings.aiGeneration.includeBody": "Generate a commit body", "desktop.settings.aiGeneration.language": "Output language", @@ -10931,18 +10951,19 @@ "desktop.settings.updates.notConfigured": "This build has no update feed. Install new versions from the release page.", "desktop.settings.updates.auto": "Check for updates automatically", "desktop.settings.updates.checking": "Checking for updates…", - "desktop.settings.updates.installing": "Installing the update…", - "desktop.settings.updates.downloading": "Downloading… {percent}%", - "desktop.settings.updates.ready": "Version {version} is downloaded and ready to install.", - "desktop.settings.updates.available": "Version {version} is available.", - "desktop.settings.updates.upToDate": "Lithe is up to date.", + "desktop.settings.updates.installing": "Installing and restarting…", + "desktop.settings.updates.downloading": "Downloading Lithe {version}… {percent}%", + "desktop.settings.updates.ready": "Lithe {version} is downloaded. Click Update to install and restart.", + "desktop.settings.updates.available": "Lithe {version} is available.", + "desktop.settings.updates.upToDate": "Lithe {version} is up to date.", + "desktop.settings.updates.row": "Check for Updates", + "desktop.settings.updates.autoHelp": "Checks 5 seconds after launch, then every hour. Checks pause while the window is in the background and catch up when it returns. The update server sees your IP address when Lithe checks.", + "desktop.settings.updates.update": "Update", "desktop.settings.updates.failed": "Update failed: {reason}", "desktop.settings.updates.lastChecked": "Last checked {time}", "desktop.settings.updates.check": "Check Now", - "desktop.settings.updates.download": "Download", - "desktop.settings.updates.install": "Install and Restart", "desktop.settings.updates.notes": "Release Notes", - "desktop.updates.available": "Lithe {version} is available. Open Settings > Updates to install it.", + "desktop.updates.available": "Lithe {version} is available. Open Settings > About to update.", "desktop.memory.status": "{total} MB · Lithe {lithe} MB", "desktop.memory.tooltip": "Memory: {total} MB in total. Lithe {lithe} MB, language servers {lsp} MB, services {services} MB.", "desktop.memory.title": "Memory", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index bf8fec24e..73d320017 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -737,9 +737,8 @@ "desktop.aiCommit.responseTooLarge": "AI 服务商的响应超过 2 MiB。", "desktop.aiCommit.stale": "生成期间暂存的更改发生了变化,请重新生成。", "desktop.aiCommit.timeout": "AI 服务商在 45 秒内没有响应。", - "desktop.app.coreVersion": "内核 {version}", "desktop.app.devBadge": "Dev", - "desktop.app.version": "版本 {version}", + "desktop.app.previewVersion": "{version} 预览版", "desktop.branch.alreadyOn": "已在“{name}”上", "desktop.branch.branchName": "分支名称", "desktop.branch.checkedOut": "已检出“{name}”", @@ -1379,6 +1378,27 @@ "desktop.semantics.renameTitle": "重命名符号", "desktop.semantics.targetUnavailable": "目标不是本地文件,暂时无法打开。", "desktop.settings.about": "关于", + "desktop.settings.about.tagline": "面向 Java 开发与 AI 协作的低内存占用 IntelliJ IDEA 替代品", + "desktop.settings.about.copy": "复制", + "desktop.settings.about.copied": "已复制", + "desktop.settings.about.copyHelp": "复制版本、平台、构建和运行时,可直接粘贴到问题反馈里。", + "desktop.settings.about.copyFailed": "复制失败:{reason}", + "desktop.settings.about.info": "版本信息", + "desktop.settings.about.version": "版本", + "desktop.settings.about.platform": "运行平台", + "desktop.settings.about.build": "构建", + "desktop.settings.about.buildUnknown": "未记录", + "desktop.settings.about.runtime": "运行时", + "desktop.settings.about.community": "加入交流", + "desktop.settings.about.qq": "QQ 群", + "desktop.settings.about.qqHelp": "群号 {number},提问、反馈和交流都可以。", + "desktop.settings.about.wechat": "微信群", + "desktop.settings.about.wechatHelp": "在飞书文档里扫码加入。", + "desktop.settings.about.project": "项目", + "desktop.settings.about.repo": "开源仓库", + "desktop.settings.about.repoHelp": "源码、发布版本和讨论都在 GitHub。", + "desktop.settings.about.issues": "问题反馈", + "desktop.settings.about.issuesHelp": "在 GitHub 上报告问题或提功能建议。", "desktop.settings.aiGeneration.help": "提交信息生成和行内编辑使用的服务商与选项。这些是 Lithe 自己的偏好,只影响本应用,不改写 Codex 或 Claude 的配置。", "desktop.settings.aiGeneration.includeBody": "生成提交正文", "desktop.settings.aiGeneration.language": "输出语言", @@ -10931,18 +10951,19 @@ "desktop.settings.updates.notConfigured": "此构建未配置更新源,请从发布页面安装新版本。", "desktop.settings.updates.auto": "自动检查更新", "desktop.settings.updates.checking": "正在检查更新…", - "desktop.settings.updates.installing": "正在安装更新…", - "desktop.settings.updates.downloading": "正在下载… {percent}%", - "desktop.settings.updates.ready": "版本 {version} 已下载,可以安装。", - "desktop.settings.updates.available": "有新版本 {version} 可用。", - "desktop.settings.updates.upToDate": "Lithe 已是最新版本。", + "desktop.settings.updates.installing": "正在安装并重启…", + "desktop.settings.updates.downloading": "正在下载 Lithe {version}… {percent}%", + "desktop.settings.updates.ready": "Lithe {version} 已下载,点“更新”即可安装并重启。", + "desktop.settings.updates.available": "Lithe {version} 可更新。", + "desktop.settings.updates.upToDate": "Lithe {version} 已是最新版本。", + "desktop.settings.updates.row": "检查更新", + "desktop.settings.updates.autoHelp": "启动 5 秒后检查一次,之后每小时一次;窗口在后台时暂停,回到前台再补查。检查时更新服务器会看到你的 IP 地址。", + "desktop.settings.updates.update": "更新", "desktop.settings.updates.failed": "更新失败:{reason}", "desktop.settings.updates.lastChecked": "上次检查:{time}", "desktop.settings.updates.check": "立即检查", - "desktop.settings.updates.download": "下载", - "desktop.settings.updates.install": "安装并重启", "desktop.settings.updates.notes": "发布说明", - "desktop.updates.available": "Lithe {version} 已发布,可在“设置 > 更新”中安装。", + "desktop.updates.available": "Lithe {version} 已发布,可在“设置 > 关于”中一键更新。", "desktop.memory.status": "{total} MB · Lithe {lithe} MB", "desktop.memory.tooltip": "内存:共 {total} MB。Lithe {lithe} MB,语言服务器 {lsp} MB,服务 {services} MB。", "desktop.memory.title": "内存", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 1900d070f..bdab2f885 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -6230,16 +6230,6 @@ "desktop" ] }, - { - "id": "desktop.app.coreVersion", - "description": "Core version shown in application information.", - "platforms": [ - "desktop" - ], - "parameters": { - "version": "string" - } - }, { "id": "desktop.app.devBadge", "description": "Development build badge on the application icon.", @@ -6248,8 +6238,8 @@ ] }, { - "id": "desktop.app.version", - "description": "Application version displayed on the welcome and settings screens.", + "id": "desktop.app.previewVersion", + "description": "The version of a build that was not packaged from its desktop-v release tag, on the start screen and in Settings > About and Updates. A release build shows the plain version.", "platforms": [ "desktop" ], @@ -14393,6 +14383,159 @@ "desktop" ] }, + { + "id": "desktop.settings.about.tagline", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.copy", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.copied", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.copyHelp", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.copyFailed", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ], + "parameters": { + "reason": "string" + } + }, + { + "id": "desktop.settings.about.info", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.version", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.platform", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.build", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.buildUnknown", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.runtime", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.community", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.qq", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.qqHelp", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "desktop.settings.about.wechat", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.wechatHelp", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.project", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.repo", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.repoHelp", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.issues", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.about.issuesHelp", + "description": "Desktop settings About page: version facts, community and project links.", + "platforms": [ + "desktop" + ] + }, { "id": "desktop.settings.aiGeneration.help", "description": "AI settings page: generation options help", @@ -15414,13 +15557,6 @@ "desktop" ] }, - { - "id": "desktop.settings.updates.download", - "description": "Updates settings page and update notices.", - "platforms": [ - "desktop" - ] - }, { "id": "desktop.settings.updates.downloading", "description": "Updates settings page and update notices.", @@ -15428,6 +15564,7 @@ "desktop" ], "parameters": { + "version": "string", "percent": "string" } }, @@ -15448,13 +15585,6 @@ "desktop" ] }, - { - "id": "desktop.settings.updates.install", - "description": "Updates settings page and update notices.", - "platforms": [ - "desktop" - ] - }, { "id": "desktop.settings.updates.installing", "description": "Updates settings page and update notices.", @@ -15506,6 +15636,30 @@ { "id": "desktop.settings.updates.upToDate", "description": "Updates settings page and update notices.", + "platforms": [ + "desktop" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "desktop.settings.updates.row", + "description": "Updates settings page and update notices.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.updates.autoHelp", + "description": "Updates settings page and update notices.", + "platforms": [ + "desktop" + ] + }, + { + "id": "desktop.settings.updates.update", + "description": "Updates settings page and update notices.", "platforms": [ "desktop" ] diff --git a/shared/platform-feature-matrix/features/settings-about-page.json b/shared/platform-feature-matrix/features/settings-about-page.json new file mode 100644 index 000000000..9abff1cea --- /dev/null +++ b/shared/platform-feature-matrix/features/settings-about-page.json @@ -0,0 +1,46 @@ +{ + "id": "settings-about-page", + "area": "设置", + "group": "应用配置", + "capability": "关于页与正式版/预览版标识", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "desktop/internal/settingswindow/model.go", + "desktop/internal/settingswindow/about_view.go", + "desktop/internal/settingswindow/about_view_test.go", + "desktop/internal/settingswindow/settingswindow_test.go", + "desktop/internal/settingswindow/visual_capture_test.go", + "desktop/internal/settingswindow/updates_view.go", + "desktop/internal/buildinfo/buildinfo.go", + "desktop/internal/buildinfo/stable.go", + "desktop/internal/buildinfo/preview.go", + "desktop/internal/workbench/welcome_view.go", + "scripts/desktop/package-desktop.sh", + "scripts/ci/desktop-release/package-release.sh" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "desktop/internal/settingswindow/model.go", + "desktop/internal/settingswindow/about_view.go", + "desktop/internal/settingswindow/about_view_test.go", + "desktop/internal/settingswindow/settingswindow_test.go", + "desktop/internal/settingswindow/visual_capture_test.go", + "desktop/internal/settingswindow/updates_view.go", + "desktop/internal/buildinfo/buildinfo.go", + "desktop/internal/buildinfo/stable.go", + "desktop/internal/buildinfo/preview.go", + "desktop/internal/workbench/welcome_view.go", + "scripts/desktop/package-desktop.sh", + "scripts/ci/desktop-release/package-release.sh" + ] + }, + "owner": "Settings", + "releaseSource": "只有 Desktop Release 为 desktop-v<版本> 标签的提交打包时传 package-desktop.sh --stable(lithe_stable 构建标签),应用内显示不带“预览版”的版本号,package-release.sh 校验每个打包程序都带这个标签;desktop-AutoBuild、本地打包和 go run 都显示“<版本> 预览版”。", + "verification": "无头窗口测试覆盖:关于页排在 AI 之后、面包屑只有“关于”;预览版显示“0.7.0 预览版”;QQ 群与 Issue 链接打开对应地址;复制成功与失败(失败时显示原因并上报);更新页当前版本卡片与“已是最新”在正式版和预览版下的文字;复制内容与平台、构建、运行时的读取(含未记录提交和时间不可读)。macOS 生成了中英文、浅色和深色的无头截图(36-settings-about)。运行 scripts/desktop/desktop-go.sh test -race ./internal/settingswindow ./internal/workbench -run 'TestAbout|TestUpdatesPageNames|TestUpdateLineSays|TestWelcomeTitleAndVersion'。真实窗口、Windows 实机和正式版安装包上的标识尚未验收。", + "notes": "Viva 设置侧栏在 AI 之后增加一级页面“关于”:顶部是图标、名称、简介和复制按钮(复制版本、平台、构建与运行时,用于提交 Issue);“版本信息”显示版本、平台、构建(提交与日期)和 Go 运行时;“更新”复用更新页的同一张卡片和状态;“加入交流”有 QQ 群 163027877 与微信群(飞书页面);“项目”有源码仓库和 Issue。不显示 Core C ABI 版本。版本号来自 desktop/viva.toml;欢迎页、关于页、更新页和复制内容都按同一规则显示正式版或预览版。" +} diff --git a/shared/platform-feature-matrix/features/updates-check-download.json b/shared/platform-feature-matrix/features/updates-check-download.json index 61112fbe7..1e2631b44 100644 --- a/shared/platform-feature-matrix/features/updates-check-download.json +++ b/shared/platform-feature-matrix/features/updates-check-download.json @@ -13,7 +13,10 @@ ".github/workflows/desktop-release.yml", "desktop/internal/workbench/updates.go", "desktop/internal/settingswindow/updates_view.go", - "desktop/internal/buildinfo/update.go" + "desktop/internal/buildinfo/update.go", + "desktop/internal/workbench/updates_test.go", + "desktop/internal/settingswindow/about_view.go", + "desktop/internal/settingswindow/about_view_test.go" ] }, "windows": { @@ -26,13 +29,16 @@ ".github/workflows/desktop-release.yml", "desktop/internal/workbench/updates.go", "desktop/internal/settingswindow/updates_view.go", - "desktop/internal/buildinfo/update.go" + "desktop/internal/buildinfo/update.go", + "desktop/internal/workbench/updates_test.go", + "desktop/internal/settingswindow/about_view.go", + "desktop/internal/settingswindow/about_view_test.go" ] }, "owner": "Release", "releaseSource": "Viva 桌面构建使用独立 desktop-v 标签和预发布包,另由 desktop-autobuild.yml 每天从 preview 滚动发布到 desktop-AutoBuild 预发布(不注入更新源,也没有独立的开发版身份);应用内更新渠道和签名验证尚未接入。", - "verification": "Viva 需要分别在 macOS 和 Windows 验证更新渠道、签名、下载、安装、取消和数据保护;当前尚无对应实机验收。", - "notes": "Viva:设置 › 更新页可检查、下载并安装:Viva 的更新包校验 latest.json 的 Ed25519 签名,按平台下载并校验大小与 SHA-256,下载放在用户缓存目录,安装后重启到新版本;启动时最多每天自动检查一次且只提示。更新源、公钥和渠道在链接时注入(buildinfo/update.go),打包与发布流程尚未注入也未发布签名 feed,所以发行构建目前不检查更新。没有专门的自动化测试。", + "verification": "Viva 需要分别在 macOS 和 Windows 验证更新渠道、签名、下载、安装、取消和数据保护;当前尚无对应实机验收。workbench 的 updates_test.go 用假的更新源覆盖:后台到点的检查推迟、回到前台后执行并只通知一次、保存检查时间并排下一次检查;自动检查失败不显示、手动检查显示原因;下载进度、下载目录在用户缓存、取消后恢复可更新且无失败;关闭自动检查后保存并撤销定时,重新打开后排上第一次检查。运行 scripts/desktop/desktop-go.sh test -race ./internal/workbench -run 'TestAutomaticUpdateCheck|TestUpdateCheckFailure|TestCancelledUpdateDownload|TestTurningOffTheAutomaticCheck'。", + "notes": "Viva:设置 › 更新页和关于页共用同一张更新卡片,可“立即检查”,有新版本时“更新”一键下载、校验、安装并重启;下载中可取消,取消后恢复为可更新且不报错。Viva 的更新包校验 latest.json 的 Ed25519 签名,按平台下载并校验大小与 SHA-256,下载放在用户缓存目录(Lithe/Updates/<版本>)。自动检查参照 Motty:启动 5 秒后第一次检查,之后每小时一次;窗口在后台时到点的检查推迟到窗口回到前台再补;同一版本只通知一次;自动检查失败不在页面显示,只有用户点“立即检查”才显示原因;自动检查开关与上次检查时间随设置保存,项目标签页共用一份状态。更新源、公钥和渠道在链接时注入(buildinfo/update.go),打包与发布流程尚未注入也未发布签名 feed,所以发行构建目前不检查更新。预览版与正式版按语义化版本比较,同号的预览版不会把正式版认作更新。", "legacy": { "revision": "5a54c41aa37a4e6e6b68918a4b05765b9dd07a36", "macos": {