feat(m159): 自更新 GUI 客户端接线 + CLI update check/apply 同一内核
D50 的「检查 → 提示 → 可拒绝 → 下载校验 → 下一次启动生效」整环接线,GUI 与
CLI 共用 core 的一条内核(`update::Job`),并顺带修掉 M157 最后一轮复审的三处 P3。
内核(core::update)
- 新增 job.rs:`Job { source, target, staging, dest }` + `check` / `apply`(复查 →
取包 → 长度/SHA256 双检 → 原子替换),`default_source`(GitHub,`ULL_UPDATE_DIR`
可切本地目录夹具)、`process_dest`、`staging_dir`。
- `mod.rs` 导出 job 一族;`source.rs` 为 `Box<dyn UpdateSource>` 补 trait 转发
(壳层要运行期选源,`UpdateChecker` 是泛型的)。
presenter(宿主通道)
- `dto/update.rs`:`UpdateView` / `UpdateOutcome`(Idle/Checking/UpToDate/Available/
MissingArtifact/Failed 六态,**失败与最新不折叠**)/ `UpdateBanner` 优先级判据。
- `host/update.rs`:Factory(生产 GitHub / 单测本地目录)+ core 结论 → DTO 的唯一映射
+ 更新域偏好(`cache/update/prefs.json`)。
- `host/update_state.rs`:Runtime(会话状态机:begin_check/adopt/begin_apply/dismiss/
set_auto_check)+ `CoreHost` 派发与后台线程。
- `AppCommand::{CheckUpdate, ApplyUpdate, DismissUpdate, SetUpdateAutoCheck}`、
`CoreEvent::Update`、`AppState.update`;`refresh_all` 按开关派发静默检查。
app-iced
- 设置页新增第 5 组「更新」(当前版本 / 渠道 / 自动检查开关 / 立即检查 / 上次检查与
结果 / 下载并安装 / 就位后立即重启),走 `settings::view` 的 `update` 形参 + 按下标
分发(与 M106 语言行同一取舍)。
- 顶部提示条(每页可见):有新版本(版本号 + 体积 + 说明摘要,可安装/可忽略)、下载中、
已就位(立即重启);静默检查失败**不**弹条(原因进设置页)。
- `pages::Message::Update(UpdateAction)` → `app/update/update_action.rs`;「立即重启」
拉同参数的新进程后退出(截图通道不重启)。
CLI
- `ull update check` / `ull update apply [--dest <path>]`:复用同一 `Job`;四种结论分开
报(`--json` 的 `status`),失败退出码 1(`update_check_failed` / `update_apply_failed`)。
i18n
- 新增 `catalog/update.rs`(30 条,链尾追加)+ 金表对应 30 行。
M157 复审 P3 三件
- `app/update/instances.rs` 模块文档改成「清脏的唯一时机是回程快照等于表单」。
- `pages/instances/demo.rs` 的 `demo_form` 注释改成现状(两个参数框早已是单行输入)。
- `pages/instances/launch_args.rs`:门限不满足时只灰 `opengl`/`vulkan`,`default` 仍可点
(它是「不下发」的安全档);判据抽成 `backend_chip_enabled` 并补 3 条用例。浅色 + 灰化
两张图:`C:/Temp/m159-shots/instances-launch-args-{light,gated}.png`。
顺带:harness 支持 `ULL_SETTINGS_GROUP=<下标>`(拍第 5 组的验收图需要它)。
验证:cargo fmt --check 绿;cargo clippy --workspace --all-targets -D warnings 绿;
cargo test --workspace --all-targets 51 个测试二进制全绿(唯一例外:core::addon::pack
一族要造 4 GiB 稀疏文件,本机磁盘 99% 满 ⇒ StorageFull,与本次改动无关);CLI 用本地
目录夹具跑通「发现 9.9.9 → 下载 → 校验 → 替换运行中的 exe → 备份留旧文件」,并验证
摘要不符时拒绝应用、目标文件零改动、暂存目录零残留。
This commit is contained in:
1 parent
a80362915a
commit
1c8f6aa1b3
44 files changed
+3341
-22
No files matched your search
@@ -42,6 +42,8 @@ pub const fn general() -> &'static str {
|
||||
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
|
||||
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
|
||||
agent config [--port N] 打印 Claude Code / Cursor 的配置样例
|
||||
update check 查一次更新(只读:不动任何文件;失败退出码 1)
|
||||
update apply [--dest <path>] 下载 → SHA256 校验 → 替换(缺省替换本进程 exe)
|
||||
version 版本 + 系统摘要(同 --version)
|
||||
help [命令] 本帮助
|
||||
|
||||
@@ -111,6 +113,24 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
|
||||
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
|
||||
data.stages 列出实际走过的阶段。",
|
||||
),
|
||||
"update" => Some(
|
||||
"%prog% update check
|
||||
%prog% update apply [--dest <path>]
|
||||
|
||||
自更新(D50:GitHub Releases 固定清单地址,先只发 stable)。
|
||||
|
||||
check 查一次更新,只读:报告当前版本、渠道、有没有新版(版本号/体积/说明摘要)。
|
||||
四种结论分开报,不折叠:up_to_date / available / missing_artifact(有新版但没本平台
|
||||
产物,属发布侧漏项)/ 失败(退出码 1,code=update_check_failed)。
|
||||
apply 先复查一次(清单只有几 KB),有新版才下载 → 长度 + SHA256 双检 → 原子替换。
|
||||
校验不符或替换被占用时**不会**留下暂存文件、也不会破坏现有 exe(自动回滚)。
|
||||
缺省替换本进程的 exe:**下次启动生效**(正在跑的这一份仍是旧版本)。
|
||||
--dest 钉到别的路径(取证 / 自定义安装位置)。不写 = 本进程 exe。
|
||||
|
||||
环境变量 ULL_UPDATE_DIR=<dir>:改走本地目录源(`<dir>/stable.json` + 产物),
|
||||
用于内网手工分发与「发现新版本」这条分支的整环取证;不设就走 GitHub。
|
||||
--json 输出 {\"data\":{\"status\":…,\"current\":…,\"version\":…}},脚本按 status 分流。",
|
||||
),
|
||||
"install" => Some(
|
||||
"\
|
||||
|
||||
@@ -14,7 +14,7 @@ pub mod program;
|
||||
pub use model::{
|
||||
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
|
||||
DiagArgs, Globals, InstallArgs, InstanceLaunchArgs, Invocation, LaunchArgs, LogsArgs,
|
||||
PackImportArgs,
|
||||
PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
|
||||
};
|
||||
pub use parse::parse;
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ pub enum Command {
|
||||
},
|
||||
/// `agent serve`:对外 MCP server(长驻)。
|
||||
AgentServe(AgentServeArgs),
|
||||
/// `update check`:查一次更新,不动任何文件。
|
||||
UpdateCheck(UpdateCheckArgs),
|
||||
/// `update apply [--dest <path>]`:下载 → 校验 → 替换(默认替换本进程 exe)。
|
||||
UpdateApply(UpdateApplyArgs),
|
||||
/// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。
|
||||
AgentConfig {
|
||||
/// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。
|
||||
@@ -76,6 +80,17 @@ pub enum Command {
|
||||
},
|
||||
}
|
||||
|
||||
/// `update check` 的参数(现在没有选项,留一个结构体以免将来加选项时改签名)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UpdateCheckArgs {}
|
||||
|
||||
/// `update apply` 的参数。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UpdateApplyArgs {
|
||||
/// 替换目标(缺省 = 本进程的 exe;取证/自定义安装位置时才给)。
|
||||
pub dest: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AgentServeArgs {
|
||||
|
||||
@@ -13,7 +13,7 @@ mod instances;
|
||||
|
||||
use super::model::{
|
||||
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
|
||||
LogsArgs, PackImportArgs,
|
||||
LogsArgs, PackImportArgs, UpdateApplyArgs, UpdateCheckArgs,
|
||||
};
|
||||
|
||||
/// `logs` 缺省尾巴行数。
|
||||
@@ -93,6 +93,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
|
||||
"java" => parse_java(tail),
|
||||
"accounts" => accounts::parse_accounts(tail),
|
||||
"agent" => agent::parse_agent(tail),
|
||||
"update" => parse_update(tail),
|
||||
other => {
|
||||
Err(CliError::usage(format!("未知命令 `{other}`"))
|
||||
.with_hint("%prog% --help 查看命令列表"))
|
||||
@@ -241,6 +242,31 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `update check` / `update apply [--dest <path>]`。
|
||||
fn parse_update(tail: &[String]) -> Result<Command, CliError> {
|
||||
let Some(sub) = tail.first().map(String::as_str) else {
|
||||
return Err(CliError::usage("update 需要子命令 check|apply")
|
||||
.with_hint("%prog% update check / %prog% update apply"));
|
||||
};
|
||||
let rest = tail.get(1..).unwrap_or_default();
|
||||
match sub {
|
||||
"check" => {
|
||||
ensure_no_extra(rest, "update check")?;
|
||||
Ok(Command::UpdateCheck(UpdateCheckArgs {}))
|
||||
}
|
||||
"apply" => {
|
||||
let parsed = parse_flags(rest, &["dest"], &[])?;
|
||||
ensure_no_positionals(&parsed, "update apply")?;
|
||||
Ok(Command::UpdateApply(UpdateApplyArgs {
|
||||
dest: parsed.value("dest").map(PathBuf::from),
|
||||
}))
|
||||
}
|
||||
other => Err(CliError::usage(format!(
|
||||
"update 的子命令只有 check|apply,收到 `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_java(tail: &[String]) -> Result<Command, CliError> {
|
||||
match tail.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod meta;
|
||||
pub mod pack;
|
||||
pub mod sessions;
|
||||
pub mod snapshot;
|
||||
pub mod update;
|
||||
|
||||
use crate::cli::{Command, Globals, Invocation};
|
||||
use crate::context::Ctx;
|
||||
@@ -57,6 +58,12 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
|
||||
java::install(ctx, *major, *vendor)
|
||||
}),
|
||||
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
|
||||
Command::UpdateCheck(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| update::check(ctx, args))
|
||||
}
|
||||
Command::UpdateApply(args) => {
|
||||
with_ctx_outcome(&invocation.globals, |ctx| update::apply(ctx, args))
|
||||
}
|
||||
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
|
||||
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
|
||||
Command::AccountsRemove { name } => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! `ull update check` / `ull update apply`(M159):**复用 core 的同一内核**。
|
||||
//!
|
||||
//! 两条命令都只做「解析参数 → 造一个 [`Job`] → 调 `check` / `apply` → 排版输出」,
|
||||
//! 版本比较、渠道准入、清单校验、长度/SHA256 双检、原子替换全在 `core::update`
|
||||
//! (GUI 走的是同一个 `Job`,见 `crates/presenter/src/host/update.rs`)。
|
||||
//!
|
||||
//! 三条不许折叠的边界:
|
||||
//!
|
||||
//! | 情形 | 输出 |
|
||||
//! |---|---|
|
||||
//! | 已是最新 | `status=up_to_date`(退出码 0) |
|
||||
//! | 有可装的更新 | `status=available`(退出码 0;`apply` 才动文件) |
|
||||
//! | 检查失败 | **错误**(退出码 1,`code=update_check_failed`)——绝不说成「已是最新」 |
|
||||
//!
|
||||
//! 还有一条单独报的:`missing_artifact`(有更高版本但里面没有本平台的产物)。那是发布侧的漏项,
|
||||
//! 同样不许说成「已是最新」。
|
||||
//!
|
||||
//! `apply` 默认替换**本进程的 exe**(`Job::for_process`);`--dest` 可以钉到别的路径(取证用)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::update::{default_source, Applied, CheckOutcome, Job, UpdateError, Version};
|
||||
|
||||
use crate::cli::{UpdateApplyArgs, UpdateCheckArgs};
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::{Outcome, Report};
|
||||
|
||||
/// D50:先只发 stable。
|
||||
const CHANNEL: unlucky_core::update::Channel = unlucky_core::update::Channel::Stable;
|
||||
|
||||
/// 本进程版本(与 `ull --version` 同源)。
|
||||
fn current_version() -> Result<Version, CliError> {
|
||||
let text = env!("CARGO_PKG_VERSION");
|
||||
Version::parse(text)
|
||||
.map_err(|error| CliError::internal("bad_version", format!("编译期版本号不合法:{error}")))
|
||||
}
|
||||
|
||||
/// 造一个内核 job(源 = GitHub / `ULL_UPDATE_DIR`,暂存 = 数据根缓存,目标 = 本进程 exe)。
|
||||
fn job(ctx: &Ctx, dest: Option<PathBuf>) -> Job {
|
||||
let engine = super::install::build_engine(ctx);
|
||||
let job = Job::for_process(default_source(Arc::new(engine)), &ctx.layout().cache());
|
||||
match dest {
|
||||
Some(dest) => job.with_dest(dest),
|
||||
None => job,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败 → CLI 错误(保留 core 的稳定分类 id 在 code 里,脚本可分流)。
|
||||
fn failure(code: &'static str, action: &str, error: &UpdateError) -> CliError {
|
||||
let hint = if error.is_retryable() {
|
||||
"网络/占用类失败,过一会儿重试一次通常就好"
|
||||
} else {
|
||||
"这不是瞬时失败:清单或版本号本身有问题,等发布侧修"
|
||||
};
|
||||
CliError::user(
|
||||
code,
|
||||
format!("{action}失败({}):{error}", error.kind.as_str()),
|
||||
)
|
||||
.with_hint(hint)
|
||||
}
|
||||
|
||||
/// `ull update check`:只查、不动任何文件。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 清单取不到 / 清单不合法 / 渠道不符 → 用户错误(退出码 1)。
|
||||
pub fn check(ctx: &Ctx, _args: &UpdateCheckArgs) -> Result<Outcome, CliError> {
|
||||
let current = current_version()?;
|
||||
let job = job(ctx, None);
|
||||
let outcome = job
|
||||
.check(¤t, CHANNEL)
|
||||
.map_err(|error| failure("update_check_failed", "检查更新", &error))?;
|
||||
Ok(Outcome::new(report_of(¤t, job.source_id(), &outcome)))
|
||||
}
|
||||
|
||||
/// `ull update apply`:复查 → 下载 → 双检 → 替换(失败自动清理/回滚)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 复查、取包、校验、替换任一步失败 → 用户错误(退出码 1)。
|
||||
pub fn apply(ctx: &Ctx, args: &UpdateApplyArgs) -> Result<Outcome, CliError> {
|
||||
let current = current_version()?;
|
||||
let job = job(ctx, args.dest.clone());
|
||||
let dest = job.dest().display().to_string();
|
||||
match job
|
||||
.apply(¤t, CHANNEL)
|
||||
.map_err(|error| failure("update_apply_failed", "应用更新", &error))?
|
||||
{
|
||||
Applied::Installed { receipt, version } => {
|
||||
let backup = receipt
|
||||
.backup
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string());
|
||||
let mut human = vec![
|
||||
format!("已下载并校验 v{version}({} 字节)", receipt.bytes),
|
||||
format!("已替换:{dest}"),
|
||||
];
|
||||
human.push(match &backup {
|
||||
Some(backup) => format!("旧版本备份:{backup}"),
|
||||
None => "原本没有旧文件(首次落盘)".to_string(),
|
||||
});
|
||||
human.push("下次启动生效(正在运行的进程仍是旧版本)。".to_string());
|
||||
Ok(Outcome::new(Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"status": "installed",
|
||||
"version": version,
|
||||
"path": dest,
|
||||
"bytes": receipt.bytes,
|
||||
"backup": backup,
|
||||
}),
|
||||
human,
|
||||
)))
|
||||
}
|
||||
Applied::Nothing(outcome) => {
|
||||
let report = report_of(¤t, job.source_id(), &outcome);
|
||||
Ok(Outcome::new(Report::new(
|
||||
json!({ "status": "nothing_to_apply", "check": report.data }),
|
||||
{
|
||||
let mut human =
|
||||
vec!["复查之后没有可应用的更新(已经不是最新版本冲突)".to_string()];
|
||||
human.extend(report.human);
|
||||
human
|
||||
},
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把检查结论排成「人读几行 + `--json` 一份」。
|
||||
fn report_of(current: &Version, source: &str, outcome: &CheckOutcome) -> Report {
|
||||
let base = |status: &str| {
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"status": status,
|
||||
})
|
||||
};
|
||||
match outcome {
|
||||
CheckOutcome::UpToDate { latest, .. } => {
|
||||
let latest = latest.as_ref().map(ToString::to_string);
|
||||
let mut data = base("up_to_date");
|
||||
data["latest"] = json!(latest);
|
||||
Report::new(
|
||||
data,
|
||||
vec![match &latest {
|
||||
Some(latest) => format!("已是最新:v{current}(渠道 {latest} 内最高)"),
|
||||
None => format!("已是最新:v{current}(该渠道还没有任何发布)"),
|
||||
}],
|
||||
)
|
||||
}
|
||||
CheckOutcome::Available(candidate) => {
|
||||
let version = candidate.release.version.to_string();
|
||||
let forced = candidate.urgency == unlucky_core::update::Urgency::Forced;
|
||||
let notes = candidate.release.notes.clone().unwrap_or_default();
|
||||
let mut human = vec![format!(
|
||||
"发现新版本 v{version}(当前 v{current},{} 字节{})",
|
||||
candidate.artifact.size,
|
||||
if forced { ",强制更新" } else { "" }
|
||||
)];
|
||||
if !notes.trim().is_empty() {
|
||||
human.push(format!("说明:{}", notes.trim()));
|
||||
}
|
||||
human.push("用 `%prog% update apply` 下载并应用(下次启动生效)".to_string());
|
||||
Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"status": "available",
|
||||
"version": version,
|
||||
"size": candidate.artifact.size,
|
||||
"forced": forced,
|
||||
"notes": notes,
|
||||
"target": candidate.artifact.target.as_str(),
|
||||
}),
|
||||
human,
|
||||
)
|
||||
}
|
||||
CheckOutcome::MissingArtifact { release } => {
|
||||
let version = release.version.to_string();
|
||||
Report::new(
|
||||
json!({
|
||||
"current": current.to_string(),
|
||||
"channel": CHANNEL.as_str(),
|
||||
"source": source,
|
||||
"status": "missing_artifact",
|
||||
"version": version,
|
||||
"target": unlucky_core::update::Target::current().as_str(),
|
||||
}),
|
||||
vec![format!(
|
||||
"v{version} 已发布,但没有为本平台({})提供产物——这是发布侧漏项,不是「已是最新」",
|
||||
unlucky_core::update::Target::current().as_str()
|
||||
)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user