Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ empaquetada en **Tauri 2.x**. Estado: Fases 0, 1 y 2 completas (parser + UI web
/ `npm run build` **sin** `--prefix web` (un `--prefix web` buscaría `web/web/package.json` y falla).
`src-tauri/` es su **propia raíz de workspace**: `cargo build` en la raíz NO arrastra el backend Tauri.
- Build del frontend: `npm --prefix web run build`.
- **Tests**: 20 tests unitarios del parser en `src/lib.rs` (`cargo test --release`). Complementan
—no reemplazan— la verificación contra datos reales del skill `/verify-parser`.
- **Tests**: 24 tests unitarios en el crate del parser (`src/lib.rs` + `src/update.rs`, `cargo test --release`).
Complementan —no reemplazan— la verificación contra datos reales del skill `/verify-parser`.
- **Update check** (`src/update.rs`, capa de red opt-in y secundaria — el parser nunca la llama): consulta
el último Release de GitHub vía `curl` (sin dep HTTP) y **solo avisa** si hay versión nueva, nunca instala.
Expuesto en CLI (`--check-update [--json]`), Tauri (`check_update`/`open_url`) y la VersionBadge.

## Modelo de datos (lo NO obvio — léelo antes de tocar el parser)
- Fuente de verdad: transcripts NATIVOS `~/.claude/projects/<dir>/<sessionId>.jsonl`. **No se usa ningún hook.**
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ installs `arrow.app` into `/Applications`, and clears the Gatekeeper quarantine

> The installer strips the quarantine attribute of an **unsigned** binary so it opens without
> friction — only run it if you trust this source. To **update**, re-run the one-liner; to
> **uninstall**, `rm -rf /Applications/arrow.app`. (There is no auto-update / package manager yet —
> a Homebrew Cask is on the [roadmap](ROADMAP.md).)
> **uninstall**, `rm -rf /Applications/arrow.app`. arrow **tells you when a newer release exists**
> (a dot on the version badge, or `arrow --check-update` from the CLI) but does not auto-install it
> yet — being unsigned, a silent in-app updater would fight Gatekeeper. A Homebrew Cask and a signed
> auto-updater are on the [roadmap](ROADMAP.md).

**Manual:** grab the `.dmg` for your chip (`_aarch64` = Apple Silicon, `_x64` = Intel) from the
[**Releases**](https://github.com/MrArcher23/arrow/releases/latest) page, open it, and drag arrow to
Expand Down Expand Up @@ -133,10 +135,13 @@ cargo build --release

# Normalized JSON (the contract the UI consumes)
./target/release/arrow --repo my-project --json

# Is there a newer arrow release? (network; read-only, never installs)
./target/release/arrow --check-update
```

Options: `--projects-dir <path>` (defaults to `~/.claude/projects`), `--repo`, `--session`,
`--list`, `--json`, and `--content --file <path> [--session <id>]` (emits `{before, after}` for a
`--list`, `--json`, `--check-update`, and `--content --file <path> [--session <id>]` (emits `{before, after}` for a
file, for the UI's diff view).

## Web UI (Phase 1)
Expand Down
10 changes: 10 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ todas deben respetar la honestidad del producto (no afirmar más de lo que el da
que no existe un `.dmg` publicado todavía: el primer `v*` lo generará. **Pendiente:** correr ese
primer tag, la firma/notarización (Apple Developer, ~99 USD/año) para quitar la fricción de
Gatekeeper, y un **Homebrew Cask** (`brew install --cask`) con upgrade/uninstall.
- **Aviso de actualización (check-for-updates)** — ✅ implementado. arrow consulta el último Release de
GitHub y avisa si hay una versión más nueva, **sin descargar ni instalar nada** (honesto: unsigned ⇒
un auto-updater silencioso pelearía con Gatekeeper, por eso solo *avisa*). Capa de red **opt-in y
secundaria** en `src/update.rs` (fuera del hot path del parser; shell-out a `curl`, sin dep HTTP
nueva, time-boxed, degrada con `error` en vez de panic). Expuesto en CLI (`--check-update [--json]`),
Tauri (comando `check_update` + `open_url` para abrir el release) y en la **VersionBadge** (punto verde
+ "Update available → vX.Y.Z" + "Open release"). Tests de orden de versión en `update.rs`.
**Pendientes (siguen en backlog):** auto-instalación real vía `tauri-plugin-updater` (requiere keypair
de firma del updater + `latest.json` firmado en el job de release; mejor **tras** la notarización) y el
Homebrew Cask de arriba para el upgrade nativo en Mac.
- **macOS sin verificar en hardware**: el código ya está adaptado a Mac (titlebar nativa + font-weight
por OS; ver [MACOS.md](MACOS.md)) y la matrix ya **construirá** el `.dmg` en CI, pero el bloque
`cfg(target_os = "macos")` y el `install.sh` aún **no se probaron en una Mac real** → falta correr
Expand Down
36 changes: 35 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ fn worktree_sizes(paths: Vec<String>) -> std::collections::HashMap<String, u64>
worktrees::worktree_sizes(&paths)
}

/// Check GitHub for a newer arrow release (for the version badge's "update
/// available" hint). Network-bound but time-boxed; read-only — it never
/// downloads or installs. Runs off the UI thread via `spawn_blocking`.
#[tauri::command]
async fn check_update() -> arrow::UpdateStatus {
tauri::async_runtime::spawn_blocking(|| arrow::check_update(env!("CARGO_PKG_VERSION")))
.await
.unwrap_or_else(|_| arrow::check_update(env!("CARGO_PKG_VERSION")))
}

/// Open a URL in the user's default browser (the "Open release" action). Only
/// `https://` URLs are honored, so the command can't be coaxed into launching a
/// local file or a `file://`/`javascript:` scheme. Argv-direct (no shell).
#[tauri::command]
fn open_url(url: String) -> Result<(), String> {
if !url.starts_with("https://") {
return Err("refusing to open a non-https URL".into());
}
#[cfg(target_os = "macos")]
let (bin, args): (&str, Vec<String>) = ("open", vec![url]);
#[cfg(target_os = "linux")]
let (bin, args): (&str, Vec<String>) = ("xdg-open", vec![url]);
#[cfg(target_os = "windows")]
let (bin, args): (&str, Vec<String>) =
("cmd", vec!["/C".into(), "start".into(), String::new(), url]);
std::process::Command::new(bin)
.args(args)
.spawn()
.map(|_| ())
.map_err(|e| format!("couldn't open the browser: {e}"))
}

/// Watcher nativo: vigila `~/.claude/projects` y, con debounce, emite
/// `report-changed` para que el frontend refresque sin polling.
///
Expand Down Expand Up @@ -158,7 +190,9 @@ pub fn run() {
editors,
open_in_editor,
worktrees,
worktree_sizes
worktree_sizes,
check_update,
open_url
])
.setup(|app| {
spawn_watcher(app.handle().clone());
Expand Down
6 changes: 6 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ use serde::Serialize;
use serde_json::Value;
use walkdir::WalkDir;

// Opt-in, secondary network layer (release-update check). Kept out of the parser
// hot path — `build_report`/`file_content` never touch it; same separation as the
// git worktree layer.
pub mod update;
pub use update::{check_update, UpdateStatus};

// ---------------------------------------------------------------------------
// Modelo interno (acumulación)
// ---------------------------------------------------------------------------
Expand Down
33 changes: 33 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ struct Cli {
/// Ruta exacta del archivo (para --content)
#[arg(long)]
file: Option<String>,

/// Check GitHub for a newer arrow release (network; read-only, never installs)
#[arg(long)]
check_update: bool,
}

const RESET: &str = "\x1b[0m";
Expand All @@ -64,6 +68,35 @@ fn main() -> Result<()> {
.clone()
.unwrap_or_else(|| format!("{home}/.claude/projects"));

if cli.check_update {
let st = arrow::check_update(env!("CARGO_PKG_VERSION"));
if cli.json {
println!("{}", serde_json::to_string(&st)?);
} else if let Some(err) = &st.error {
println!("{YELLOW}Couldn't check for updates:{RESET} {err}");
println!("{DIM}current: v{}{RESET}", st.current);
} else if st.update_available {
let latest = st.latest.as_deref().unwrap_or("?");
println!(
"{GREEN}{BOLD}Update available:{RESET} v{} {DIM}→{RESET} {GREEN}v{latest}{RESET}",
st.current
);
if let Some(url) = &st.url {
println!("{DIM}{url}{RESET}");
}
println!(
"{DIM}macOS: re-run the installer · curl -fsSL \
https://raw.githubusercontent.com/MrArcher23/arrow/main/install.sh | bash{RESET}"
);
} else {
println!(
"{GREEN}arrow is up to date{RESET} {DIM}(v{}){RESET}",
st.current
);
}
return Ok(());
}

if cli.content {
let target = cli
.file
Expand Down
174 changes: 174 additions & 0 deletions src/update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//! Update check — a lightweight "is there a newer release?" probe.
//!
//! arrow ships **unsigned** (no Apple notarization yet), so a silent in-app
//! auto-updater (tauri-plugin-updater) would fight Gatekeeper; that's deferred.
//! What we CAN do honestly is *tell* the user a newer version exists and point
//! them at the release. This module does exactly that and nothing more — it
//! never downloads or installs anything.
//!
//! It is an OPT-IN, secondary network layer, kept out of the parser's hot path
//! (`build_report`/`file_content` never call it) — same separation as the git
//! worktree layer. To avoid pulling an HTTP client into the core lib, it shells
//! out to `curl` (present on macOS and the Linux targets), argv-direct, with a
//! hard `--max-time`, and degrades gracefully (a missing curl, no network, a
//! rate-limit, or unparseable JSON all yield `error: Some(..)`, never a panic).

use serde::Serialize;
use serde_json::Value;
use std::process::Command;

/// GitHub repo that publishes arrow's releases (the upstream, not a fork).
const RELEASES_REPO: &str = "MrArcher23/arrow";

/// Outcome of an update check. `update_available` is only ever `true` when a
/// `latest` was fetched AND parsed AND is strictly newer than `current`.
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// The running version (e.g. "0.1.6"), without a leading "v".
pub current: String,
/// The latest published release tag, normalized without "v". `None` when the
/// check failed or no release exists.
pub latest: Option<String>,
/// Strictly-newer latest than current.
pub update_available: bool,
/// Release page to open / share when an update is available.
pub url: Option<String>,
/// Human-readable reason the check couldn't complete (network, no release,
/// curl missing). `None` on a clean check (whether or not an update exists).
pub error: Option<String>,
}

impl UpdateStatus {
fn failed(current: &str, error: impl Into<String>) -> Self {
UpdateStatus {
current: normalize(current).to_string(),
latest: None,
update_available: false,
url: None,
error: Some(error.into()),
}
}
}

/// Check whether a release newer than `current` exists. Network-bound but
/// time-boxed; safe to call from a button. Never downloads or installs.
pub fn check_update(current: &str) -> UpdateStatus {
let url = format!("https://api.github.com/repos/{RELEASES_REPO}/releases/latest");
let out = Command::new("curl")
.args([
"-fsSL",
"--max-time",
"6",
"-H",
"Accept: application/vnd.github+json",
// GitHub rejects requests without a User-Agent.
"-A",
"arrow-update-check",
&url,
])
.output();

let body = match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
// curl ran but the request failed (404 = no release yet, 403 = rate
// limited, etc.). `-f` makes curl exit non-zero on HTTP errors.
Ok(_) => {
return UpdateStatus::failed(current, "no published release found (or rate-limited)")
}
Err(_) => return UpdateStatus::failed(current, "couldn't run curl to check for updates"),
};

let json: Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => return UpdateStatus::failed(current, "couldn't parse the release response"),
};

let latest_tag = match json.get("tag_name").and_then(Value::as_str) {
Some(t) if !t.is_empty() => t.to_string(),
_ => return UpdateStatus::failed(current, "the release had no tag"),
};
let page = json
.get("html_url")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("https://github.com/{RELEASES_REPO}/releases/latest"));

let cur = normalize(current);
let lat = normalize(&latest_tag);
UpdateStatus {
current: cur.to_string(),
update_available: is_newer(lat, cur),
latest: Some(lat.to_string()),
url: Some(page),
error: None,
}
}

/// Strip a leading "v"/"V" so "v0.1.6" and "0.1.6" compare equal.
fn normalize(v: &str) -> &str {
v.trim().strip_prefix(['v', 'V']).unwrap_or(v.trim())
}

/// Is `a` a strictly newer semver-ish version than `b`? Compares dot-separated
/// numeric components (missing components count as 0), so "0.2.0" > "0.1.9" and
/// "0.1.6" == "0.1.6". A non-numeric component stops the comparison conservatively
/// (returns false), so a weird tag never spuriously claims an update.
fn is_newer(a: &str, b: &str) -> bool {
let parse = |s: &str| -> Option<Vec<u64>> {
// Drop any pre-release/build suffix (e.g. "0.2.0-rc1" -> "0.2.0").
let core = s.split(['-', '+']).next().unwrap_or(s);
core.split('.').map(|p| p.parse::<u64>().ok()).collect()
};
match (parse(a), parse(b)) {
(Some(av), Some(bv)) => {
let n = av.len().max(bv.len());
for i in 0..n {
let x = av.get(i).copied().unwrap_or(0);
let y = bv.get(i).copied().unwrap_or(0);
if x != y {
return x > y;
}
}
false
}
_ => false,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn version_ordering() {
assert!(is_newer("0.2.0", "0.1.9"));
assert!(is_newer("0.1.7", "0.1.6"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(!is_newer("0.1.6", "0.1.6"));
assert!(!is_newer("0.1.6", "0.2.0"));
// Padding: "0.1" == "0.1.0".
assert!(!is_newer("0.1", "0.1.0"));
assert!(is_newer("0.1.1", "0.1"));
// Pre-release suffix dropped to the core.
assert!(is_newer("0.2.0-rc1", "0.1.9"));
// Non-numeric junk never claims an update.
assert!(!is_newer("nightly", "0.1.6"));
}

#[test]
fn normalize_strips_v() {
assert_eq!(normalize("v0.1.6"), "0.1.6");
assert_eq!(normalize("0.1.6"), "0.1.6");
assert_eq!(normalize(" v0.1.6 "), "0.1.6");
}

#[test]
fn failed_status_is_honest() {
let s = UpdateStatus::failed("v0.1.6", "boom");
assert_eq!(s.current, "0.1.6");
assert!(!s.update_available);
assert!(s.latest.is_none());
assert_eq!(s.error.as_deref(), Some("boom"));
}
}
Loading
Loading