Add terminal client, memory, model fallback, version and install improvements
- codecraft: full-screen terminal client (ratatui) – local tools and files, models via the CodeCraft server gateway or own providers (standalone with a guided setup), slash commands, @files, permission dialogs, resume, update - Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the server: /cli/install.sh and /cli/download/<target> - Project memory (remember tool, .codecraft/memory.md) and personal memory - Model fallback when a host is unreachable or a budget is used up - Version/commit in UI and health, daily release check, "What's new" dialog - PWA: install button, maskable icon, manifest id and screenshots - Case-insensitive detection of connection errors for retries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa1f8cf016
commit
6dffcb501a
52 files changed
+6477
-765
No files matched your search
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# Install the CodeCraft terminal client (codecraft) from __CODECRAFT_URL__
|
||||
# curl -fsSL __CODECRAFT_URL__/cli/install.sh | sh
|
||||
# Options (environment): CODECRAFT_INSTALL_DIR (default ~/.local/bin)
|
||||
set -eu
|
||||
|
||||
SERVER="__CODECRAFT_URL__"
|
||||
DIR="${CODECRAFT_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64) arch=x86_64 ;;
|
||||
aarch64|arm64) arch=aarch64 ;;
|
||||
*) echo "codecraft: unsupported architecture $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$os" in
|
||||
linux) ;;
|
||||
*) echo "codecraft: no prebuilt binary for $os yet – build from source: cargo install --git <repo> codecraft-cli" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
target="$os-$arch"
|
||||
url="$SERVER/cli/download/$target"
|
||||
mkdir -p "$DIR"
|
||||
tmp="$DIR/.codecraft.download"
|
||||
echo "Downloading codecraft ($target) from $SERVER …"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$url" -o "$tmp"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q "$url" -O "$tmp"
|
||||
else
|
||||
echo "codecraft: needs curl or wget" >&2; exit 1
|
||||
fi
|
||||
chmod +x "$tmp"
|
||||
mv "$tmp" "$DIR/codecraft"
|
||||
echo "Installed $DIR/codecraft ($("$DIR/codecraft" --version 2>/dev/null || echo codecraft))"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$DIR:"*) ;;
|
||||
*) echo "Note: $DIR is not in your PATH – add this to your shell profile:"
|
||||
echo " export PATH=\"$DIR:\$PATH\"" ;;
|
||||
esac
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " codecraft login $SERVER # sign in with your CodeCraft account"
|
||||
echo " codecraft # start in your project folder"
|
||||
+48
-2
@@ -158,7 +158,53 @@ pub fn router(app: AppState) -> Router {
|
||||
let index = web_dir.join("index.html");
|
||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
|
||||
|
||||
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
.route("/cli/install.sh", get(cli_install_script))
|
||||
.route("/cli/download/{target}", get(cli_download))
|
||||
.fallback_service(static_files)
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
fn cli_dir() -> PathBuf {
|
||||
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
|
||||
}
|
||||
|
||||
/// The server's public base URL (config, else from the request).
|
||||
fn public_base(app: &App, headers: &axum::http::HeaderMap) -> String {
|
||||
let cfg = app.config();
|
||||
if !cfg.auth.public_url.is_empty() {
|
||||
return cfg.auth.public_url.trim_end_matches('/').to_string();
|
||||
}
|
||||
let host = headers.get("x-forwarded-host").or(headers.get(axum::http::header::HOST)).and_then(|v| v.to_str().ok()).unwrap_or("localhost:8787");
|
||||
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).unwrap_or("http");
|
||||
format!("{proto}://{host}")
|
||||
}
|
||||
|
||||
/// `curl -fsSL <server>/cli/install.sh | sh` installs the terminal client.
|
||||
async fn cli_install_script(State(app): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
let base = public_base(&app, &headers);
|
||||
let script = include_str!("../resources-cli/install.sh").replace("__CODECRAFT_URL__", &base);
|
||||
([(axum::http::header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], script).into_response()
|
||||
}
|
||||
|
||||
/// Static CLI binaries, e.g. /cli/download/linux-x86_64.
|
||||
async fn cli_download(Path(target): Path<String>) -> Response {
|
||||
if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return (StatusCode::BAD_REQUEST, "invalid target").into_response();
|
||||
}
|
||||
let path = cli_dir().join(format!("codecraft-{target}"));
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => (
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()),
|
||||
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"codecraft\"".to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, format!("no codecraft build for {target} on this server")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a session the user may at least read.
|
||||
@@ -1022,7 +1068,7 @@ async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -
|
||||
|
||||
/// Liveness for monitoring (no auth needed – see auth::is_public).
|
||||
async fn health(State(app): State<AppState>) -> Json<Value> {
|
||||
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
|
||||
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
|
||||
}
|
||||
|
||||
// --- budget -------------------------------------------------------------------
|
||||
|
||||
@@ -77,6 +77,8 @@ pub struct App {
|
||||
pub push: crate::push::Push,
|
||||
/// Web terminals (PTYs).
|
||||
pub terminals: crate::terminal::Terminals,
|
||||
/// Newer release found by the update check (admins see it).
|
||||
pub update: crate::update::UpdateState,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
@@ -116,6 +118,7 @@ impl App {
|
||||
lsp: Default::default(),
|
||||
push: crate::push::Push::new(&data),
|
||||
terminals: Default::default(),
|
||||
update: Default::default(),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
|
||||
+3
-1
@@ -253,7 +253,9 @@ pub async fn info(State(app): State<Arc<App>>, headers: HeaderMap) -> Json<Value
|
||||
let user = current_user(&app, &headers);
|
||||
Json(json!({
|
||||
"name": "CodeCraft",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"version": crate::update::VERSION,
|
||||
"commit": crate::update::commit(),
|
||||
"update": if user.as_ref().is_some_and(|u| u.is_admin()) { app.update.latest.lock().unwrap().clone() } else { None },
|
||||
"setup_required": app.setup_code.lock().unwrap().is_some(),
|
||||
"oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null },
|
||||
"user": user,
|
||||
|
||||
@@ -254,6 +254,8 @@ pub struct ServerConfig {
|
||||
pub budget_action: String,
|
||||
/// Largest file upload in MB (uploads are sent in chunks, so the reverse proxy limit doesn't apply).
|
||||
pub max_upload_mb: u64,
|
||||
/// Where to look for new releases (Gitea/GitHub "latest release" API); empty = no check.
|
||||
pub update_url: String,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -272,6 +274,7 @@ impl Default for ServerConfig {
|
||||
budget_warning: 0.8,
|
||||
budget_action: "warn".into(),
|
||||
max_upload_mb: 500,
|
||||
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ mod store;
|
||||
mod terminal;
|
||||
mod tools;
|
||||
mod types;
|
||||
mod update;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -55,6 +56,7 @@ async fn main() -> Result<()> {
|
||||
push::spawn_listener(app.clone());
|
||||
terminal::spawn_reaper(app.clone());
|
||||
jobs::spawn_scheduler(app.clone());
|
||||
update::spawn_checker(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
tracing::info!("listening on http://{bind}");
|
||||
|
||||
+12
-49
@@ -35,56 +35,8 @@ pub struct Agent {
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.";
|
||||
pub use codecraft_core::prompts::*;
|
||||
|
||||
pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
||||
|
||||
# Working style
|
||||
- Be direct and concise. Lead with what you did or found; no filler.
|
||||
- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns.
|
||||
- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output.
|
||||
- Prefer editing existing files over creating new ones. Never create documentation files unless asked.
|
||||
- Use todo_write to plan and track multi-step work; keep exactly one item in progress.
|
||||
- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response.
|
||||
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
||||
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
||||
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||
- Reference code as path:line.
|
||||
- Answer in the language the user writes in."#;
|
||||
|
||||
const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project.";
|
||||
|
||||
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
|
||||
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
|
||||
|
||||
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a phase.
|
||||
|
||||
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
|
||||
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
|
||||
3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width.
|
||||
4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes.
|
||||
|
||||
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
|
||||
|
||||
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
|
||||
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
|
||||
|
||||
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
||||
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
||||
|
||||
/// Default review criteria per mode (used when the config gives none).
|
||||
pub fn refine_criteria(agent: &str) -> &'static str {
|
||||
match agent {
|
||||
"plan" => "Is the plan complete and concrete (files, functions, order of steps)? Are risks, edge cases, migration and verification covered? Are open questions for the user stated? Is anything in it wrong for this codebase?",
|
||||
"craft" => "Were the phases done properly (study, build, check)? Did the CHECK step really compare the result with the original (screenshots, outputs) and report remaining differences? Is the result original work (no copied code/assets)? What still differs visibly or behaviourally?",
|
||||
_ => "Is the request fully implemented – every requirement, not just the main path? Is the code correct (logic, edge cases, error handling)? Were the changes verified (build, tests, running it) and do they pass? Any leftovers (TODOs, debug output, unused code), broken existing behaviour or missing docs/tests for the change?",
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft prompt for clean room or deep mode.
|
||||
pub fn craft_prompt(clean_room: bool) -> String {
|
||||
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF })
|
||||
}
|
||||
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = craft_prompt(clean_room);
|
||||
@@ -270,6 +222,17 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
|
||||
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
|
||||
}
|
||||
}
|
||||
if let Some(u) = owner {
|
||||
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
|
||||
if !memory.trim().is_empty() {
|
||||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||||
}
|
||||
}
|
||||
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
|
||||
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
|
||||
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
|
||||
Err(_) => {}
|
||||
}
|
||||
let files = instruction_files(app, root, cwd);
|
||||
if !files.is_empty() {
|
||||
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
|
||||
|
||||
+25
-3
@@ -300,7 +300,18 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
reasoning: true,
|
||||
model: model.clone(),
|
||||
};
|
||||
let step = stream_step(&app, &session, &agent.name, &model_spec, provider, req, &cancel).await?;
|
||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
||||
Ok(step) => step,
|
||||
// Host down or budget used up: switch to the model's fallback for this step.
|
||||
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
|
||||
let fb = model.fallback.clone().unwrap_or_default();
|
||||
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
|
||||
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
final_text = step
|
||||
.message
|
||||
.parts
|
||||
@@ -747,9 +758,20 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||
(chars as f64 / 3.8) as u64 + images * 1000
|
||||
}
|
||||
|
||||
/// Errors after which the fallback model takes over: the host is unreachable
|
||||
/// (connection, timeout, gateway errors) or the provider's budget is used up.
|
||||
fn needs_fallback(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
|
||||
}
|
||||
|
||||
fn short_error(e: &anyhow::Error) -> String {
|
||||
format!("{e:#}").lines().next().unwrap_or("").chars().take(120).collect()
|
||||
}
|
||||
|
||||
fn is_retryable(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}");
|
||||
s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out")
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["http 429", "http 5", "overloaded", "connect", "timed out", "timeout"].iter().any(|k| s.contains(k))
|
||||
}
|
||||
|
||||
/// One model request with streaming to the UI, usage recording and retries.
|
||||
|
||||
@@ -97,6 +97,45 @@ impl Tool for CraftPhase {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Project memory: lasting facts about the project in .codecraft/memory.md.
|
||||
pub struct Remember;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Remember {
|
||||
fn name(&self) -> &str {
|
||||
"remember"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to \
|
||||
.codecraft/memory.md – it is loaded into every future session in this project. Keep entries short; \
|
||||
never store secrets or temporary state."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({"type": "object", "properties": {"note": {"type": "string"}}, "required": ["note"]})
|
||||
}
|
||||
fn is_edit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
input["note"].as_str().unwrap_or("").chars().take(80).collect()
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let note = arg_str(&input, "note")?.trim().replace('\n', " ");
|
||||
let path = ctx.resolve(".codecraft/memory.md")?;
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into());
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&format!("- {note}\n"));
|
||||
std::fs::write(&path, &text)?;
|
||||
Ok(ToolOutput::text(format!("Saved to .codecraft/memory.md: {note}")).with_meta(json!({"memory": note})))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Switch the session's working directory (only on the user's request).
|
||||
pub struct ChangeDirectory;
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(web::WebSearch),
|
||||
Arc::new(agent::CraftPhase),
|
||||
Arc::new(agent::ChangeDirectory),
|
||||
Arc::new(agent::Remember),
|
||||
Arc::new(cad::OpenScad),
|
||||
Arc::new(crate::jobs::Schedule),
|
||||
Arc::new(crate::lsp::Diagnostics),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Version information and a daily check for new releases.
|
||||
|
||||
use crate::app::App;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
|
||||
pub fn commit() -> &'static str {
|
||||
option_env!("CODECRAFT_COMMIT").unwrap_or("")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UpdateState {
|
||||
pub latest: Mutex<Option<Value>>,
|
||||
}
|
||||
|
||||
fn newer(a: &str, b: &str) -> bool {
|
||||
let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
|
||||
p(a) > p(b)
|
||||
}
|
||||
|
||||
async fn check(app: &App) -> Option<Value> {
|
||||
let url = app.config().server.update_url.clone();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let v: Value = crate::provider::http_client().get(&url).timeout(Duration::from_secs(20)).send().await.ok()?.json().await.ok()?;
|
||||
let tag = v["tag_name"].as_str()?.to_string();
|
||||
if !newer(&tag, VERSION) {
|
||||
return None;
|
||||
}
|
||||
Some(json!({"version": tag.trim_start_matches('v'), "url": v["html_url"], "notes": v["body"].as_str().unwrap_or("").chars().take(4000).collect::<String>(), "published_at": v["published_at"]}))
|
||||
}
|
||||
|
||||
pub fn spawn_checker(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
loop {
|
||||
let found = check(&app).await;
|
||||
*app.update.latest.lock().unwrap() = found;
|
||||
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn version_compare() {
|
||||
assert!(super::newer("v0.2.0", "0.1.9"));
|
||||
assert!(super::newer("1.0.0", "0.9.9"));
|
||||
assert!(!super::newer("v0.1.0", "0.1.0"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user