Browse Source

initial commit

main v0.1.0
xenua 1 month ago
commit
089c4937c1
Signed by: xenua
GPG Key ID: 8F93B68BD37255B8
  1. 1
      .gitignore
  2. 2187
      Cargo.lock
  3. 23
      Cargo.toml
  4. 7
      build.rs
  5. 65
      src/args.rs
  6. 151
      src/client.rs
  7. 37
      src/common.rs
  8. 239
      src/fetcher.rs
  9. 145
      src/gts.rs
  10. 31
      src/logging.rs
  11. 20
      src/main.rs
  12. 180
      src/mastodon.rs
  13. 77
      src/misc.rs
  14. 65
      src/node_info.rs
  15. 106
      src/runtime_info.rs

1
.gitignore vendored

@ -0,0 +1 @@
/target

2187
Cargo.lock generated

File diff suppressed because it is too large Load Diff

23
Cargo.toml

@ -0,0 +1,23 @@
[package]
name = "fedi_emoji_downloader"
version = "0.1.0"
edition = "2021"
[dependencies]
bytes = "1.8.0"
chrono = { version = "0.4.38", features = ["serde"] }
clap = { version = "4.5.21", features = ["derive"] }
color-eyre = "0.6.3"
gay_tracing_formatter = { git = "https://git.xenua.me/xenua/tracing_formatter.git", tag = "v0.1.2" }
inquire = "0.7.5"
reqwest = { version = "0.12.9", features = ["rustls-tls"], default-features = false }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
sysinfo = { version = "0.32.0", features = ["system", "windows"], default-features = false }
term-detect = "0.1.7"
tokio = { version = "1.41.1", features = ["full"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
[build-dependencies]
rustc_version = "0.4.1"

7
build.rs

@ -0,0 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let rcv = rustc_version::version_meta()?;
println!("cargo::rustc-env=FED_RUSTC_VER={}", rcv.semver);
Ok(())
}

65
src/args.rs

@ -0,0 +1,65 @@
use std::{
path::PathBuf,
sync::{Arc, OnceLock},
};
use clap::Parser;
use color_eyre::eyre::{eyre, Result, WrapErr};
use crate::misc::FediSoftware;
#[derive(clap::Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[arg(short, long)]
pub no_interactive: bool,
#[arg(short, long)]
pub instance: Option<String>,
#[arg(short, long)]
pub output_dir: Option<PathBuf>,
#[arg(short, long)]
pub software: Option<FediSoftware>,
}
pub fn get() -> Arc<Args> {
static ARGS: OnceLock<Arc<Args>> = OnceLock::new();
ARGS.get_or_init(|| Arc::new(Args::parse())).clone()
}
impl Args {
pub fn try_get_instance(&self) -> Result<String> {
static INSTANCE_CACHE: OnceLock<String> = OnceLock::new();
if let Some(inst) = INSTANCE_CACHE.get() {
return Ok(inst.clone());
}
if let Some(inst) = self.instance.as_ref() {
return Ok(sanitize_instance(inst.clone()));
}
if self.no_interactive {
return Err(eyre!(
"don't know which instance, and `--no-interactive` was specified! giving up :c"
));
}
inquire::prompt_text("Which instance?")
.map(sanitize_instance)
.inspect(|inst| {
INSTANCE_CACHE.set(inst.clone()).expect("is only set once");
})
.wrap_err("could not prompt for text input")
}
}
fn sanitize_instance(inst: String) -> String {
if inst.starts_with("http://") || inst.starts_with("https://") {
inst
} else {
format!("https://{inst}")
}
.trim()
.trim_end_matches("/")
.to_owned()
}

151
src/client.rs

@ -0,0 +1,151 @@
use std::{fmt::Debug, str::FromStr};
use chrono::{DateTime, TimeDelta, Utc};
use color_eyre::eyre::{eyre, OptionExt, Result, WrapErr};
use reqwest::{
header::{HeaderMap, HeaderValue},
Client as RClient, IntoUrl, Method, RequestBuilder,
};
use serde::Deserialize;
use tracing::{info, instrument, trace, warn};
use crate::{
misc::{format_timedelta, FediSoftware},
node_info::{NodeInfo, WellKnownNodeInfo},
runtime_info::RuntimeInfo,
};
#[derive(Debug)]
pub struct Client {
pub client: RClient,
pub base_url: String,
pub meta: RuntimeInfo,
}
impl Client {
pub fn new(base_url: String) -> Self {
Self {
client: RClient::new(),
base_url,
meta: RuntimeInfo::init(),
}
}
pub fn domain(&self) -> &str {
self.base_url
.rsplit_once("//")
.expect("base_url contains //")
.1
}
#[instrument(level = "trace")]
pub fn request<U: IntoUrl + Debug>(&self, method: Method, url: U) -> RequestBuilder {
self.client
.request(method, url)
.header("User-Agent", self.meta.user_agent())
}
pub fn request_endpoint(&self, method: Method, endpoint: &str) -> RequestBuilder {
self.request(method, format!("{}{}", &self.base_url, endpoint))
}
pub async fn query_nodeinfo(&self) -> Result<FediSoftware> {
let resp: WellKnownNodeInfo = self
.get_and_deserialize_response_json(
self.request_endpoint(Method::GET, "/.well-known/nodeinfo"),
)
.await?;
let url = resp
.links
.into_iter()
.find_map(|lnk| match lnk.version() {
"2.0" | "2.1" => Some(lnk.href),
_ => None,
})
.ok_or_eyre(
"no supported nodeinfo version found! supported versions are 2.0 and 2.1",
)?;
let node_info: NodeInfo = self
.get_and_deserialize_response_json(self.request(Method::GET, &url))
.await?;
Ok(node_info.software.name.into())
}
pub async fn honor_rate_limit(headers: HeaderMap) -> Result<()> {
let limit: Option<usize> = Self::parse_header_via_str(&headers, "x-ratelimit-limit")?;
let remaining: Option<usize> =
Self::parse_header_via_str(&headers, "x-ratelimit-remaining")?;
let reset: Option<DateTime<Utc>> =
Self::parse_header_via_str(&headers, "x-ratelimit-reset")?;
let lim = limit.map(|l| l / 10).unwrap_or(1);
if remaining.is_some_and(|r| r < lim) {
if let Some(reset_time) = reset {
let now = Utc::now();
let diff = reset_time - now;
if diff > TimeDelta::zero() {
info!(
"rate limit reached! waiting {} until reset",
format_timedelta(&diff)
)
}
}
}
Ok(())
}
fn parse_header_via_str<T: FromStr>(
headers: &HeaderMap,
header_name: &str,
) -> Result<Option<T>> {
headers
.get(header_name)
.map(HeaderValue::to_str)
.transpose()
.wrap_err("couldn't get string from header value")?
.map(|lim| lim.parse())
.transpose()
.map_err(|_| eyre!("couldn't parse string"))
}
pub async fn get_and_deserialize_response_json<O: for<'a> Deserialize<'a>>(
&self,
request_builder: RequestBuilder,
) -> Result<O> {
let request = request_builder.build().wrap_err("couldn't build request")?;
let to = request.url().as_str().to_owned();
trace!(?request, ?to);
let (rate_limit_wait, text_fetch) = self
.client
.execute(request)
.await
.wrap_err(format!("could not send request to `{to}`"))
.and_then(|resp| {
let status = resp.status();
let headers = resp.headers().clone();
let rate_limit_wait_fut = Self::honor_rate_limit(headers);
status
.is_success()
.then(|| (rate_limit_wait_fut, resp.text()))
.ok_or_eyre(format!("request returned status `{}`", status))
})?;
let deserialized_res = text_fetch
.await
.inspect(|text| trace!(?text))
.wrap_err("couldn't get response body as text")
.and_then(|text| {
serde_json::from_str(&text).wrap_err("couldn't deserialize json response")
});
rate_limit_wait
.await
.inspect_err(|e| warn!("failed waiting for rate limit: {e}"))
.ok();
deserialized_res
}
}

37
src/common.rs

@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
#[allow(unused)]
pub struct Token {
pub access_token: String,
pub scope: Option<String>,
pub token_type: String,
pub created_at: usize,
}
#[derive(Serialize)]
pub struct AccessTokenRequest {
pub redirect_uri: String,
pub client_id: String,
pub client_secret: String,
pub grant_type: String,
pub code: String,
pub scope: String,
}
impl AccessTokenRequest {
pub fn from_client_id_secret_and_auth_token(
client_id: String,
client_secret: String,
code: String,
) -> Self {
Self {
redirect_uri: "urn:ietf:wg:oauth:2.0:oob".into(),
client_id,
client_secret,
grant_type: "authorization_code".into(),
code,
scope: "read".into(),
}
}
}

239
src/fetcher.rs

@ -0,0 +1,239 @@
use std::{fmt::Display, fs, future::Future, path::PathBuf};
use color_eyre::eyre::{eyre, Result, WrapErr};
use reqwest::{Method, RequestBuilder};
use tracing::{debug, error, info, trace};
use crate::{
args,
client::Client,
gts, mastodon,
misc::{CustomEmoji, FediSoftware},
};
#[derive(Debug)]
pub struct EmojiFetcher {
output_dir: PathBuf,
client: Client,
}
#[derive(Clone, Debug)]
struct FetchableEmoji {
name: String,
url: String,
category: Option<String>,
fetched: bool,
}
impl From<CustomEmoji> for FetchableEmoji {
fn from(ce: CustomEmoji) -> Self {
Self {
name: ce.shortcode,
url: ce.url,
category: ce.category,
fetched: false,
}
}
}
impl Display for FetchableEmoji {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"`{}{}`",
self.category
.as_ref()
.map(|c| format!("{c}/"))
.unwrap_or("".into()),
self.name
)
}
}
impl EmojiFetcher {
pub fn new() -> Result<Self> {
let args = args::get();
trace!(?args);
let base_url = args.try_get_instance()?;
let default_output_dir = PathBuf::from(
base_url
.split_once("//")
.expect("base_url always contains a `//`")
.1,
);
let this = Self {
output_dir: args.output_dir.clone().unwrap_or_else(|| {
info!("outputting to `{}`", default_output_dir.display());
default_output_dir
}),
client: Client::new(base_url),
};
info!("fedi_emoji_downloader initialized");
trace!(?this);
Ok(this)
}
async fn needs_interactive<T>(
&self,
op_name: &str,
op: impl Future<Output = Result<T>>,
) -> Result<T> {
if args::get().no_interactive {
error!("`{op_name}` is only possible in interactive mode!");
return Err(eyre!("exiting due to `--no-interactive`"));
}
op.await
}
pub async fn fetch(&self) -> Result<()> {
let software = self
.automatically_get_target_software()
.await
.unwrap_or_default();
debug!("detected software as `{}`", &software);
self.fetch_appropriate(software).await?;
info!(
"done! you can find your emojis in the `{}` directory",
self.output_dir.display()
);
Ok(())
}
async fn fetch_appropriate(&self, software: FediSoftware) -> Result<()> {
match software {
FediSoftware::Mastodon => self.try_fetch_mastodon().await,
FediSoftware::Gotosocial => {
self.needs_interactive("logging into gotosocial", self.try_fetch_gts())
.await
}
FediSoftware::Other(other) => {
error!("unsupported fedi software: `{other}`");
Ok(())
}
FediSoftware::Unknown => {
self.needs_interactive(
"asking for software choice after auto-detect fails",
self.ask_target_and_try_fetch(),
)
.await
}
}
}
async fn automatically_get_target_software(&self) -> Result<FediSoftware> {
debug!("detecting software");
self.client.query_nodeinfo().await
}
async fn try_fetch_mastodon(&self) -> Result<()> {
info!("trying to fetch emojis from mastodon");
info!("trying without authentication");
let unauth_res = self.do_fetch(|rb| rb).await;
if unauth_res.is_ok() {
info!("successfully fetched from mastodon without authentication");
return Ok(());
}
info!("seems like authentication is needed; asking necessary info");
let access_token = self
.needs_interactive(
"logging into mastodon",
mastodon::auth_interactive(&self.client),
)
.await?;
mastodon::verify_login(&self.client, &access_token).await?;
self.do_fetch(|rb| rb.bearer_auth(&access_token)).await?;
mastodon::invalidate_token(&self.client, &access_token).await
}
async fn try_fetch_gts(&self) -> Result<()> {
info!("trying to fetch from gotosocial");
let access_token = gts::auth_interactive(&self.client).await?;
gts::verify_login(&self.client, &access_token).await?;
self.do_fetch(|rb| rb.bearer_auth(&access_token)).await?;
Ok(())
}
async fn ask_target_and_try_fetch(&self) -> Result<()> {
info!("Could not auto-detect which software the instance uses.");
let options = vec![
FediSoftware::Mastodon,
FediSoftware::Gotosocial,
FediSoftware::Other("".into()),
];
let answer = inquire::Select::new("Please select the instance software:", options)
.prompt()
.wrap_err("failed asking for instance software selection")?;
Box::pin(self.fetch_appropriate(answer)).await
}
async fn do_fetch(&self, auth: impl Fn(RequestBuilder) -> RequestBuilder) -> Result<()> {
let mut emoji_list: Vec<FetchableEmoji> = self
.client
.get_and_deserialize_response_json::<Vec<CustomEmoji>>(auth(
self.client
.request_endpoint(Method::GET, "/api/v1/custom_emojis"),
))
.await?
.into_iter()
.map(FetchableEmoji::from)
.collect();
info!("fetching {} emojis", emoji_list.len());
for emoji in emoji_list.iter_mut().filter(|e| !e.fetched) {
info!("fetching {emoji}");
let resp = self
.client
.request(Method::GET, &emoji.url)
.send()
.await
.wrap_err("error fetching emoji")?;
let bytes = resp
.bytes()
.await
.wrap_err("error getting emoji file content")?;
let file_ext = emoji
.url
.rsplit_once(".")
.expect("file url should contain a `.`")
.1;
let file_name = emoji.name.clone() + "." + file_ext;
emoji.fetched = true;
let out_dir = if let Some(cat) = emoji.category.as_ref() {
self.output_dir.join(cat)
} else {
self.output_dir.clone()
};
let outfile = out_dir.join(file_name);
fs::create_dir_all(out_dir).wrap_err("could not create output directory")?;
fs::write(outfile, bytes).wrap_err("could not write output file")?;
}
assert!(emoji_list.iter().all(|e| e.fetched));
Ok(())
}
}

145
src/gts.rs

@ -0,0 +1,145 @@
use color_eyre::eyre::{Result, WrapErr};
use inquire::prompt_text;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::{info, trace, warn};
use crate::{
client::Client,
common::{AccessTokenRequest, Token},
misc::CustomEmoji,
};
#[derive(Serialize)]
pub struct AppCreation {
pub client_name: String,
pub redirect_uris: String,
pub scopes: Option<String>,
pub website: Option<String>,
}
impl AppCreation {
pub fn with_client_name(client_name: String) -> Self {
Self {
client_name,
redirect_uris: "urn:ietf:wg:oauth:2.0:oob".into(),
scopes: Some("read".into()),
website: None,
}
}
}
#[derive(Debug, Deserialize)]
#[allow(unused)]
pub struct Application {
pub client_id: String,
pub client_secret: String,
pub id: Option<String>,
pub name: String,
pub redirect_uri: Option<String>,
pub vapid_key: Option<String>,
pub website: Option<String>,
}
type JsonObject = Map<String, Value>;
#[derive(Deserialize)]
#[allow(unused)]
struct User {
id: String,
username: String,
acct: String,
url: String,
display_name: String,
theme: String,
suspended: Option<bool>,
statuses_count: usize,
source: JsonObject,
roles: Vec<JsonObject>,
role: JsonObject,
note: String,
moved: Option<JsonObject>,
locked: bool,
last_status_at: String,
hide_collections: Option<bool>,
header_static: String,
header_media_id: String,
header_description: Option<String>,
header: String,
following_count: usize,
followers_count: usize,
fields: Vec<JsonObject>,
enable_rss: Option<bool>,
emojis: Vec<CustomEmoji>,
discoverable: bool,
custom_css: Option<String>,
created_at: String,
bot: bool,
avatar_static: String,
avatar_media_id: String,
avatar_description: Option<String>,
avatar: String,
}
pub async fn auth_interactive(client: &Client) -> Result<String> {
trace!("authenticating on gts");
let body = serde_json::to_string(&AppCreation::with_client_name(crate::APP_NAME.to_string()))
.expect("AppCreation can be serialized to json");
trace!(?body);
let created_application: Application = client
.get_and_deserialize_response_json(
client
.request_endpoint(Method::POST, "/api/v1/apps")
.header("Content-Type", "application/json")
.body(body),
)
.await?;
trace!(?created_application);
let url = format!("{}/oauth/authorize?client_id={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read", &client.base_url, &created_application.client_id);
let auth_code = prompt_text(format!("logging into gts: {}", url))
.wrap_err("couldn't read auth token from stdin")?;
let body = serde_json::to_string(&AccessTokenRequest::from_client_id_secret_and_auth_token(
created_application.client_id,
created_application.client_secret,
auth_code,
))
.expect("AccessTokenRequest can be serialized to json");
let token_created: Token = client
.get_and_deserialize_response_json(
client
.request_endpoint(Method::POST, "/oauth/token")
.header("Content-Type", "application/json")
.body(body),
)
.await?;
Ok(token_created.access_token)
}
pub async fn verify_login(client: &Client, token: &str) -> Result<()> {
let user: User = client
.get_and_deserialize_response_json(
client
.request_endpoint(Method::GET, "/api/v1/accounts/verify_credentials")
.bearer_auth(token),
)
.await?;
info!("logged in as `@{}@{}`", user.username, client.domain());
warn!(
"{} {}",
"currently, gts does not have a way of programmatically revoking the token that was generated.",
"it will not be saved, but you may want to revoke it."
);
Ok(())
}

31
src/logging.rs

@ -0,0 +1,31 @@
use std::{env, str::FromStr};
use color_eyre::eyre::Result;
use gay_tracing_formatter::GayFormattingLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer};
pub(crate) fn setup() -> Result<()> {
let level = format!(
"warn,fedi_emoji_downloader={}",
if cfg!(debug_assertions) {
"debug"
} else {
"info"
}
);
let level = if let Ok(filter_string) = env::var("RUST_LOG") {
EnvFilter::from_str(&filter_string)?.boxed()
} else {
EnvFilter::from_str(&level)?.boxed()
};
let the_gay = GayFormattingLayer::with_crate_name(Some(crate::APP_NAME.to_owned()));
tracing_subscriber::registry()
.with(the_gay)
.with(level)
.init();
Ok(())
}

20
src/main.rs

@ -0,0 +1,20 @@
mod args;
mod client;
mod common;
mod fetcher;
mod gts;
mod logging;
mod mastodon;
mod misc;
mod node_info;
mod runtime_info;
pub const APP_NAME: &str = env!("CARGO_PKG_NAME");
#[tokio::main]
async fn main() -> color_eyre::eyre::Result<()> {
color_eyre::install()?;
logging::setup()?;
fetcher::EmojiFetcher::new()?.fetch().await
}

180
src/mastodon.rs

@ -0,0 +1,180 @@
use std::sync::OnceLock;
use color_eyre::eyre::{eyre, Result, WrapErr};
use inquire::prompt_text;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::{error, info};
use crate::{
client::Client,
common::{AccessTokenRequest, Token},
misc::CustomEmoji,
};
#[derive(Serialize)]
pub struct AppCreation {
pub client_name: String,
pub redirect_uris: Vec<String>,
pub scopes: Option<String>,
pub website: Option<String>,
}
impl AppCreation {
pub fn with_client_name(client_name: String) -> Self {
Self {
client_name,
redirect_uris: vec!["urn:ietf:wg:oauth:2.0:oob".into()],
scopes: Some("read".into()),
website: None,
}
}
}
#[derive(Debug, Deserialize)]
#[allow(unused)]
struct CredentialApplication {
name: String,
website: Option<String>,
scopes: Vec<String>,
redirect_uris: Vec<String>,
redirect_uri: Option<String>,
vapid_key: Option<String>,
client_id: String,
client_secret: String,
client_secret_expires_at: Option<String>,
}
static APP: OnceLock<CredentialApplication> = OnceLock::new();
#[derive(Deserialize)]
#[allow(unused)]
struct User {
id: String,
username: String,
acct: String,
url: String,
display_name: String,
note: String,
avatar: String,
avatar_static: String,
header: String,
header_static: String,
locked: bool,
fields: Vec<Map<String, Value>>,
emojis: Vec<CustomEmoji>,
bot: bool,
group: bool,
noindex: Option<bool>,
moved: Option<Option<Box<User>>>,
suspended: Option<bool>,
limited: Option<bool>,
created_at: String,
last_status_at: Option<String>,
statuses_count: usize,
followers_count: usize,
following_count: usize,
}
#[derive(Serialize)]
struct RevokeToken {
client_id: String,
client_secret: String,
token: String,
}
pub async fn auth_interactive(client: &Client) -> Result<String> {
let body = serde_json::to_string(&AppCreation::with_client_name(crate::APP_NAME.to_string()))
.expect("AppCreation can be serialized to json");
let app: CredentialApplication = client
.get_and_deserialize_response_json(
client
.request_endpoint(Method::POST, "/api/v1/apps")
.header("Content-Type", "application/json")
.body(body),
)
.await?;
APP.set(app)
.expect("mastodon::auth_interactive is only set once");
let app = APP.get().expect("APP is set from the line above");
let url = format!("{}/oauth/authorize?client_id={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read", &client.base_url, &app.client_id);
info!("about to log into mastodon");
info!("open this url: {url}");
let auth_token = prompt_text("paste the token from your instance web page here: ")
.wrap_err("couldn't read auth token from stdin")?;
let body = serde_json::to_string(&AccessTokenRequest::from_client_id_secret_and_auth_token(
app.client_id.clone(),
app.client_secret.clone(),
auth_token,
))
.expect("AccessTokenRequest can be serialized to json");
let token_created: Token = client
.get_and_deserialize_response_json(
client
.request_endpoint(Method::POST, "/oauth/token")
.header("Content-Type", "application/json")
.body(body),
)
.await?;
Ok(token_created.access_token)
}
pub async fn verify_login(client: &Client, token: &str) -> Result<()> {
let user: User = client
.get_and_deserialize_response_json(
client
.request_endpoint(Method::GET, "/api/v1/accounts/verify_credentials")
.bearer_auth(token),
)
.await?;
info!("logged in as `@{}@{}`", user.username, client.domain());
Ok(())
}
pub async fn invalidate_token(client: &Client, token: &str) -> Result<()> {
let app = APP.get().expect("APP is initialized");
client
.request_endpoint(Method::POST, "/oauth/revoke")
.bearer_auth(token)
.body(
serde_json::to_string(&RevokeToken {
client_id: app.client_id.clone(),
client_secret: app.client_secret.clone(),
token: token.to_owned(),
})
.expect("RevokeToken is serializeable as json"),
)
.send()
.await
.wrap_err("revoke request failed, see above")
.and_then(|resp| {
resp.status()
.is_success()
.then_some(())
.ok_or_else(|| eyre!("revoke failed, see above"))
})
.inspect_err(|_| {
error!("failed revoking the mastodon access token!");
info!(
"you can revoke it manually at {}/oauth/authorized_applications",
&client.base_url
);
})?;
info!("oauth token successfully revoked");
Ok(())
}

77
src/misc.rs

@ -0,0 +1,77 @@
use std::fmt::Display;
use chrono::TimeDelta;
use serde::Deserialize;
#[derive(Clone, Debug, Default)]
pub enum FediSoftware {
Mastodon,
Gotosocial,
Other(String),
#[default]
Unknown,
}
impl<S> From<S> for FediSoftware
where
S: AsRef<str>,
{
fn from(value: S) -> Self {
match value.as_ref() {
"mastodon" => FediSoftware::Mastodon,
"gotosocial" => FediSoftware::Gotosocial,
"" => FediSoftware::Unknown,
other => FediSoftware::Other(other.to_owned()),
}
}
}
impl Display for FediSoftware {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FediSoftware::Mastodon => write!(f, "Mastodon"),
FediSoftware::Gotosocial => write!(f, "Gotosocial"),
FediSoftware::Other(_) => write!(f, "Other Software"),
FediSoftware::Unknown => write!(f, "Unknown Software"),
}
}
}
pub fn format_timedelta(delta: &TimeDelta) -> String {
let mut out = String::new();
let days = delta.num_days();
let hours = delta.num_hours() % 24;
let minutes = delta.num_minutes() % 60;
let seconds = delta.num_seconds() % 60;
if days > 0 {
out.push_str(&format!("{days} days, "));
}
if hours > 0 || days > 0 {
out.push_str(&format!("{hours} hours, "));
}
if minutes > 0 || hours > 0 || days > 0 {
out.push_str(&format!("{minutes} minutes, "));
}
if seconds > 0 || minutes > 0 || hours > 0 || days > 0 {
out.push_str(&format!(
"{}{seconds} seconds",
if minutes > 0 || hours > 0 || days > 0 {
"and "
} else {
""
}
))
}
out
}
#[derive(Deserialize)]
#[allow(unused)]
pub struct CustomEmoji {
pub shortcode: String,
pub url: String,
pub static_url: String,
pub visible_in_picker: bool,
pub category: Option<String>,
}

65
src/node_info.rs

@ -0,0 +1,65 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct WellKnownNodeInfo {
pub links: Vec<WKNIEntry>,
}
#[derive(Debug, Deserialize)]
pub struct WKNIEntry {
pub rel: String,
pub href: String,
}
impl WKNIEntry {
pub fn version(&self) -> &str {
self.rel.rsplit_once("/").map(|(_, ver)| ver).unwrap_or("")
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(unused)]
pub struct NodeInfo {
pub version: String,
pub software: NISoftware,
pub protocols: Vec<String>,
pub services: NIServices,
pub open_registrations: bool,
pub usage: NIUsage,
pub metadata: serde_json::Value,
}
#[derive(Deserialize)]
#[allow(unused)]
pub struct NISoftware {
pub name: String,
pub version: String,
pub repository: Option<String>,
pub homepage: Option<String>,
}
#[derive(Deserialize)]
#[allow(unused)]
pub struct NIServices {
pub inbound: Vec<String>,
pub outbound: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(unused)]
pub struct NIUsage {
pub users: NIUsers,
pub local_posts: Option<usize>,
pub local_comments: Option<usize>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(unused)]
pub struct NIUsers {
pub total: Option<usize>,
pub active_halfyear: Option<usize>,
pub active_month: Option<usize>,
}

106
src/runtime_info.rs

@ -0,0 +1,106 @@
use std::{
env,
io::{self, IsTerminal},
sync::OnceLock,
};
use sysinfo::System;
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const XENUA_VERSION: &str = "13.1";
const RUSTC_VERSION: &str = env!("FED_RUSTC_VER");
#[derive(Debug)]
pub struct RuntimeInfo {
os_name: String,
os_version: Option<String>,
arch: String,
term: Option<String>,
hostname: Option<String>,
}
impl RuntimeInfo {
pub fn init() -> Self {
let os_name = sysinfo::IS_SUPPORTED_SYSTEM
.then(System::name)
.flatten()
.unwrap_or(env::consts::OS.to_string());
let term = io::stdin().is_terminal().then(Self::_term_string).flatten();
let arch = sysinfo::IS_SUPPORTED_SYSTEM
.then(System::cpu_arch)
.flatten()
.unwrap_or(env::consts::ARCH.to_string());
let os_version = sysinfo::IS_SUPPORTED_SYSTEM
.then(System::os_version)
.flatten()
.filter(|osv| osv != "rolling")
.or_else(|| {
sysinfo::IS_SUPPORTED_SYSTEM
.then(System::kernel_version)
.flatten()
});
let hostname = sysinfo::IS_SUPPORTED_SYSTEM
.then(System::host_name)
.flatten();
Self {
os_name,
term,
arch,
os_version,
hostname,
}
}
pub fn user_agent(&self) -> &str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE.get_or_init(|| {
format!(
"xenua/{xenua_ver} ({os}{os_ver}{hostname} {arch}{term}) rustc/{rustc_ver} {pkg_name}/{fed_ver}",
os = &self.os_name,
arch = &self.arch,
os_ver = self.os_version.as_ref().map(|v| format!(" {v}")).unwrap_or_default(),
term = self
.term
.as_ref()
.map(|t| format!("; {t}"))
.unwrap_or_default(),
hostname = self.hostname.as_ref().map(|h| format!(" \"{h}\"")).unwrap_or_default(),
fed_ver = APP_VERSION,
pkg_name = crate::APP_NAME,
rustc_ver = RUSTC_VERSION,
xenua_ver = XENUA_VERSION,
)
})
}
#[cfg(unix)]
fn _term_string() -> Option<String> {
let tdetect = term_detect::get_terminal().map(|t| t.0).ok();
let shell = env::var("SHELL").ok().and_then(|v| {
v.rsplit_once("/")
.map(|(_, shell_bin_name)| shell_bin_name.to_owned())
});
match (tdetect, shell) {
(None, None) => None,
(None, Some(s)) => Some(s),
(Some(t), None) => Some(t),
(Some(t), Some(s)) => Some(format!("{t} / {s}")),
}
}
#[cfg(windows)]
fn _term_string() -> Option<String> {
Some("windows_terminal".into())
}
#[cfg(not(any(unix, windows)))]
fn _term_string() -> Option<String> {
None
}
}
Loading…
Cancel
Save