xenua
1 month ago
commit
089c4937c1
15 changed files with 3334 additions and 0 deletions
@ -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" |
@ -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(()) |
||||||
|
} |
@ -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() |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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(), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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(()) |
||||||
|
} |
||||||
|
} |
@ -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(()) |
||||||
|
} |
@ -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(()) |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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(()) |
||||||
|
} |
@ -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>, |
||||||
|
} |
@ -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>, |
||||||
|
} |
@ -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…
Reference in new issue