use std::process::{Command, Stdio};
use anyhow::{Context, Error, Result};
use reqwest::{Client, RequestBuilder};
use semver::Version;
pub const REPO_SLUG: &str = "rayfish/rayfish";
pub 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 fn normalize_version(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
pub fn version_is_newer(latest: &str, current: &str) -> bool {
match (Version::parse(latest), Version::parse(current)) {
(Ok(l), Ok(c)) => l > c,
_ => latest != current,
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
pub 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 = 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 fn authed(req: RequestBuilder, token: &Option<String>) -> RequestBuilder {
match token {
Some(t) => req.bearer_auth(t),
None => req,
}
}
#[derive(serde::Deserialize)]
pub struct GhRelease {
pub tag_name: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub prerelease: bool,
#[serde(default)]
pub body: Option<String>,
}
pub fn build_http_client() -> Result<Client> {
let _ = rustls::crypto::ring::default_provider().install_default();
Client::builder()
.user_agent(concat!("ray/", env!("CARGO_PKG_VERSION")))
.build()
.context("failed to build HTTP client")
}
pub async fn resolve_stable_release(client: &Client, token: &Option<String>) -> Result<GhRelease> {
let api = format!("https://api.github.com/repos/{REPO_SLUG}/releases/latest");
let release: GhRelease = authed(client.get(&api), token)
.send()
.await?
.error_for_status()?
.json()
.await
.context("failed to query the GitHub releases API (is a release published yet?)")?;
Ok(release)
}
pub fn asset_download_url(tag: &str, asset: &str) -> String {
format!("https://github.com/{REPO_SLUG}/releases/download/{tag}/{asset}")
}
pub async fn fetch_checksum(client: &Client, tag: &str, asset: &str) -> Result<String> {
let sha_url = format!("{}.sha256", asset_download_url(tag, asset));
let sha_text = client
.get(&sha_url)
.send()
.await?
.error_for_status()
.with_context(|| format!("no checksum at {sha_url}"))?
.text()
.await
.context("failed to fetch the published checksum")?;
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");
}
Ok(expected)
}
pub async fn download_and_swap(
client: &Client,
bin_url: &str,
expected: &str,
asset: &str,
) -> Result<()> {
let bytes = client
.get(bin_url)
.send()
.await?
.error_for_status()
.with_context(|| format!("no release asset at {bin_url}"))?
.bytes()
.await
.map_err(Error::from)
.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);
Ok(())
}
pub fn should_attempt_target(
target: &str,
last_target: Option<&str>,
last_attempt_unix: Option<i64>,
now_unix: i64,
backoff_secs: i64,
) -> bool {
match (last_target, last_attempt_unix) {
(Some(t), Some(at)) if t == target => now_unix.saturating_sub(at) >= backoff_secs,
_ => true,
}
}
pub fn trigger_detached_restart() {
#[cfg(target_os = "linux")]
let mut cmd = {
let mut c = Command::new("systemd-run");
c.args(["--scope", "systemctl", "restart", "rayfish"]);
c
};
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = Command::new("launchctl");
c.args(["kickstart", "-k", "system/com.rayfish.vpn"]);
c
};
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
tracing::error!("auto-update: self-restart not supported on this platform");
return;
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
match cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => tracing::info!("auto-update: service restart scheduled"),
Err(e) => {
tracing::error!(error = %e, "auto-update: failed to schedule service restart")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attempts_a_fresh_target() {
assert!(should_attempt_target("v1.0.0", None, None, 1000, 86_400));
}
#[test]
fn attempts_a_different_target_immediately() {
assert!(should_attempt_target(
"v2.0.0",
Some("v1.0.0"),
Some(1000),
1001,
86_400
));
}
#[test]
fn backs_off_repeat_of_same_target_inside_window() {
assert!(!should_attempt_target(
"v1.0.0",
Some("v1.0.0"),
Some(1000),
1001,
86_400
));
}
#[test]
fn retries_same_target_after_window() {
assert!(should_attempt_target(
"v1.0.0",
Some("v1.0.0"),
Some(1000),
1000 + 86_400,
86_400
));
}
}