use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use reqwest::Client;
use crate::*;
use rayfish::update::{
GhRelease, REPO_SLUG, authed, download_and_swap, fetch_checksum, github_token,
normalize_version, release_asset_name, sha256_hex, version_is_newer,
};
pub(crate) fn dir_writable(dir: &Path) -> bool {
let probe = dir.join(".ray-update-probe");
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
pub(crate) fn print_release_notes(tag: &str, body: Option<&str>) {
println!("\n {tag}");
if let Some(b) = body.map(str::trim).filter(|b| !b.is_empty()) {
for line in b.lines() {
println!(" {line}");
}
}
}
pub(crate) async fn print_pending_changelog(
client: &Client,
token: &Option<String>,
current: &str,
latest: &str,
release: &GhRelease,
nightly: bool,
pinned: bool,
) {
if nightly || pinned {
if release
.body
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
return;
}
println!("\nRelease notes for {}:", release.tag_name);
print_release_notes(&release.tag_name, release.body.as_deref());
println!();
return;
}
let api = format!("https://api.github.com/repos/{REPO_SLUG}/releases?per_page=100");
let req = authed(client.get(&api), token).timeout(Duration::from_secs(5));
let releases: Vec<GhRelease> = match req.send().await {
Ok(resp) => match resp.error_for_status() {
Ok(resp) => resp.json().await.unwrap_or_default(),
Err(_) => return,
},
Err(_) => return,
};
let relevant: Vec<&GhRelease> = releases
.iter()
.filter(|r| !r.prerelease)
.filter(|r| {
let v = normalize_version(&r.tag_name);
version_is_newer(v, current) && !version_is_newer(v, latest)
})
.collect();
if relevant.is_empty() {
return;
}
println!("\nChanges in v{current} → v{latest}:");
for r in relevant {
print_release_notes(&r.tag_name, r.body.as_deref());
}
println!();
}
pub(crate) async fn cmd_update(
force: bool,
check: bool,
nightly: bool,
list: bool,
version: Option<String>,
) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let asset = release_asset_name(std::env::consts::OS, std::env::consts::ARCH)?;
let _ = rustls::crypto::ring::default_provider().install_default();
let client = Client::builder()
.user_agent(concat!("ray/", env!("CARGO_PKG_VERSION")))
.build()
.context("failed to build HTTP client")?;
let token = github_token();
if list {
return cmd_update_list(&client, &token, current).await;
}
let pinned_tag = version.as_ref().map(|v| {
let v = v.strip_prefix('v').unwrap_or(v);
format!("v{v}")
});
let spinner = progress::spinner("checking for updates…");
let api = if let Some(tag) = &pinned_tag {
format!("https://api.github.com/repos/{REPO_SLUG}/releases/tags/{tag}")
} else if nightly {
format!("https://api.github.com/repos/{REPO_SLUG}/releases/tags/nightly")
} else {
format!("https://api.github.com/repos/{REPO_SLUG}/releases/latest")
};
let release: GhRelease = (async {
authed(client.get(&api), &token)
.send()
.await?
.error_for_status()?
.json()
.await
})
.await
.context(if let Some(tag) = &pinned_tag {
format!("failed to find release {tag} (see `ray update --list`)")
} else if nightly {
"failed to query the nightly pre-release (is one published yet?)".to_string()
} else {
"failed to query the GitHub releases API (is a release published yet?)".to_string()
})?;
spinner.finish_and_clear();
let tag = release.tag_name.clone();
let latest = normalize_version(&tag);
let remote_label = if nightly {
release
.name
.clone()
.unwrap_or_else(|| "nightly".to_string())
} else {
format!("v{latest}")
};
let base = format!("https://github.com/{REPO_SLUG}/releases/download/{tag}");
let bin_url = format!("{base}/{asset}");
let spinner = progress::spinner("checking for updates…");
let expected = fetch_checksum(&client, &tag, &asset).await?;
spinner.finish_and_clear();
let up_to_date = if pinned_tag.is_some() {
latest == current
} else if nightly {
match std::env::current_exe().and_then(std::fs::read) {
Ok(bytes) => sha256_hex(&bytes) == expected,
Err(_) => false,
}
} else {
!version_is_newer(latest, current)
};
if check {
println!("current: {FULL_VERSION}");
println!("latest: {remote_label}");
if let Some(daemon_version) = daemon_version().await
&& daemon_version != current
{
println!("daemon: {daemon_version} (stale — run `sudo ray update` to restart it)");
}
if up_to_date {
println!("rayfish is up to date");
} else {
print_pending_changelog(
&client,
&token,
current,
latest,
&release,
nightly,
pinned_tag.is_some(),
)
.await;
let flag = if nightly {
" --nightly".to_string()
} else if let Some(v) = &version {
format!(" --version {v}")
} else {
String::new()
};
println!("run `sudo ray update{flag}` to upgrade");
}
return Ok(());
}
if up_to_date && !force {
println!("rayfish is already up to date ({remote_label})");
return Ok(());
}
print_pending_changelog(
&client,
&token,
current,
latest,
&release,
nightly,
pinned_tag.is_some(),
)
.await;
download_verify_and_install(&client, &bin_url, &expected, &asset, current, &remote_label).await
}
async fn cmd_update_list(client: &Client, token: &Option<String>, current: &str) -> Result<()> {
let spinner = progress::spinner("fetching releases…");
let api = format!("https://api.github.com/repos/{REPO_SLUG}/releases?per_page=30");
let releases: Vec<GhRelease> = (async {
authed(client.get(&api), token)
.send()
.await?
.error_for_status()?
.json()
.await
})
.await
.context("failed to list releases")?;
spinner.finish_and_clear();
if releases.is_empty() {
println!("no releases published yet");
return Ok(());
}
for r in &releases {
let installed = if normalize_version(&r.tag_name) == current {
" (installed)"
} else {
""
};
let kind = if r.prerelease { " [pre-release]" } else { "" };
println!("{}{kind}{installed}", r.tag_name);
}
Ok(())
}
async fn download_verify_and_install(
client: &Client,
bin_url: &str,
expected: &str,
asset: &str,
current: &str,
remote_label: &str,
) -> Result<()> {
let service_installed = service_unit_exists();
let exe = std::env::current_exe().context("failed to determine current executable path")?;
let needs_root =
service_installed || exe.parent().map(|dir| !dir_writable(dir)).unwrap_or(true);
if needs_root {
require_root()?;
}
let spinner = progress::spinner(format!("downloading {asset} ({remote_label})…"));
let res = download_and_swap(client, bin_url, expected, asset).await;
spinner.finish_and_clear();
res?;
println!("updated rayfish v{current} → {remote_label}");
if service_installed {
install_and_start_service(None).await
} else {
println!("run `sudo ray up` to start the service with the new binary");
Ok(())
}
}
pub(crate) async fn daemon_version() -> Option<String> {
let mut stream = ipc::connect().await.ok()?;
ipc::send(&mut stream, ipc::IpcMessage::Status).await.ok()?;
match ipc::recv(&mut stream).await.ok()? {
ipc::IpcMessage::StatusResponse { daemon_version, .. } if !daemon_version.is_empty() => {
Some(daemon_version)
}
_ => None,
}
}
pub(crate) const DAEMON_REACHABLE_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) async fn wait_for_daemon(timeout: Duration) -> Option<ipc::IpcFramed> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(stream) = ipc::connect().await {
return Some(stream);
}
if Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
pub(crate) fn print_daemon_log_tail() {
#[cfg(target_os = "macos")]
{
let path = "/var/log/rayfish.log";
match std::fs::read_to_string(path) {
Ok(contents) => {
let tail: Vec<&str> = contents.lines().rev().take(15).collect();
if tail.is_empty() {
eprintln!("\n(daemon log {path} is empty)");
} else {
eprintln!("\nLast lines of {path}:");
for line in tail.into_iter().rev() {
eprintln!(" {line}");
}
}
}
Err(e) => eprintln!("\n(could not read daemon log {path}: {e})"),
}
}
#[cfg(target_os = "linux")]
{
eprintln!("\nRecent daemon log (journalctl -u rayfish):");
run_cmd("journalctl", &["-u", "rayfish", "-n", "15", "--no-pager"]);
}
}
#[allow(dead_code)]
pub(crate) fn run_cmd(program: &str, args: &[&str]) {
match Command::new(program).args(args).status() {
Ok(status) if status.success() => {}
Ok(status) => eprintln!("warning: `{program}` exited with {status}"),
Err(e) => eprintln!("warning: failed to run `{program}`: {e}"),
}
}
#[allow(dead_code)]
pub(crate) fn run_cmd_quiet(program: &str, args: &[&str]) {
let _ = Command::new(program)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
pub(crate) fn cmd_uninstall_service() -> Result<()> {
#[cfg(target_os = "linux")]
{
let path = Path::new("/etc/systemd/system/rayfish.service");
if path.exists() {
run_cmd("systemctl", &["disable", "--now", "rayfish"]);
std::fs::remove_file(path)?;
run_cmd("systemctl", &["daemon-reload"]);
println!("Removed systemd service.");
} else {
println!("Service not installed.");
}
return Ok(());
}
#[cfg(target_os = "macos")]
{
let path = Path::new("/Library/LaunchDaemons/com.rayfish.vpn.plist");
if path.exists() {
run_cmd("launchctl", &["unload", "-w", &path.to_string_lossy()]);
std::fs::remove_file(path)?;
println!("Removed launchd daemon.");
} else {
println!("Service not installed.");
}
return Ok(());
}
#[allow(unreachable_code)]
{
anyhow::bail!("service uninstallation not supported on this platform");
}
}