use crate::*;
pub(crate) fn release_asset_name(os: &str, arch: &str) -> Result<String> {
let os = match os {
"linux" => "linux",
"macos" => "macos",
other => anyhow::bail!("no rayfish release binary for OS '{other}'; build from source"),
};
let arch = match arch {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
other => {
anyhow::bail!("no rayfish release binary for architecture '{other}'; build from source")
}
};
Ok(format!("ray-{os}-{arch}"))
}
pub(crate) fn normalize_version(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
pub(crate) fn version_is_newer(latest: &str, current: &str) -> bool {
match (
semver::Version::parse(latest),
semver::Version::parse(current),
) {
(Ok(l), Ok(c)) => l > c,
_ => latest != current,
}
}
pub(crate) fn dir_writable(dir: &std::path::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,
}
}
#[derive(serde::Deserialize)]
pub(crate) struct GhRelease {
tag_name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
prerelease: bool,
#[serde(default)]
body: Option<String>,
}
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: &reqwest::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(std::time::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) fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub(crate) fn github_token() -> Option<String> {
for var in ["GH_TOKEN", "GITHUB_TOKEN"] {
if let Ok(v) = std::env::var(var) {
let v = v.trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
}
let out = std::process::Command::new("gh")
.args(["auth", "token"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
(!token.is_empty()).then_some(token)
}
pub(crate) fn authed(req: reqwest::RequestBuilder, token: &Option<String>) -> reqwest::RequestBuilder {
match token {
Some(t) => req.bearer_auth(t),
None => req,
}
}
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 = reqwest::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 sha_url = format!("{bin_url}.sha256");
let spinner = progress::spinner("checking for updates…");
let sha_text = (async {
client
.get(&sha_url)
.send()
.await?
.error_for_status()
.with_context(|| format!("no checksum at {sha_url}"))?
.text()
.await
.map_err(anyhow::Error::from)
})
.await
.context("failed to fetch the published checksum")?;
spinner.finish_and_clear();
let expected = sha_text
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
if expected.is_empty() {
anyhow::bail!("no checksum published for {asset}; aborting for safety");
}
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: &reqwest::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: &reqwest::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 bytes = (async {
client
.get(bin_url)
.send()
.await?
.error_for_status()
.with_context(|| format!("no release asset at {bin_url}"))?
.bytes()
.await
.map_err(anyhow::Error::from)
})
.await;
spinner.finish_and_clear();
let bytes = bytes.context("download failed")?;
let actual = sha256_hex(&bytes);
if actual != expected {
anyhow::bail!(
"checksum mismatch for {asset}\n expected: {expected}\n got: {actual}"
);
}
let tmp = std::env::temp_dir().join(format!("{asset}.new"));
std::fs::write(&tmp, &bytes).with_context(|| format!("failed to write {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.context("failed to set executable permissions on the downloaded binary")?;
}
self_replace::self_replace(&tmp).context("failed to replace the running binary")?;
let _ = std::fs::remove_file(&tmp);
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: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) async fn wait_for_daemon(timeout: std::time::Duration) -> Option<ipc::IpcFramed> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Ok(stream) = ipc::connect().await {
return Some(stream);
}
if std::time::Instant::now() >= deadline {
return None;
}
tokio::time::sleep(std::time::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 std::process::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 _ = std::process::Command::new(program)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
pub(crate) fn cmd_uninstall_service() -> Result<()> {
#[cfg(target_os = "linux")]
{
let path = std::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 = std::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");
}
}