1#[macro_use]
2extern crate tracing;
3
4use std::{
5 env::Args,
6 ffi::OsStr,
7 fs::{create_dir_all, File},
8 io::{BufRead, BufReader, Error},
9 path::{Path, PathBuf},
10 process::{Command, Stdio},
11 sync::LazyLock,
12 time::Duration,
13};
14
15use anyhow::{bail, Result};
16use chrono::Local;
17use colored::{Color, Colorize};
18use crossbeam::channel::{Receiver, Sender};
19use lazy_regex::{lazy_regex, regex_replace_all, Lazy, Regex};
20use notify::{Config, Event, PollWatcher, RecursiveMode, Watcher};
21use parking_lot::RwLock;
22use terminal_link::Link;
23
24use crate::{
25 provider::{prelude::*, Provider, ProviderKind},
26 settings::Settings,
27};
28
29#[cfg(feature = "discord")]
30pub mod discord;
31pub mod provider;
32pub mod settings;
33pub mod vrchat;
34#[cfg(windows)]
35pub mod windows;
36
37pub const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
38pub const CARGO_PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE");
39pub const USER_AGENT: &str = concat!(
40 "VRC-LOG/",
41 env!("CARGO_PKG_VERSION"),
42 " shaybox@shaybox.com"
43);
44
45#[must_use]
46pub fn get_local_time() -> String {
47 Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
48}
49
50pub async fn check_for_updates() -> reqwest::Result<bool> {
53 let response = reqwest::get(CARGO_PKG_HOMEPAGE).await?;
54 if let Some(mut segments) = response.url().path_segments() {
55 if let Some(remote_version) = segments.next_back() {
56 return Ok(remote_version > CARGO_PKG_VERSION);
57 }
58 }
59
60 Ok(false)
61}
62
63pub fn watch<P: AsRef<Path>>(
66 tx: Sender<PathBuf>,
67 path: P,
68 millis: u64,
69) -> notify::Result<PollWatcher> {
70 let path = path.as_ref();
71 debug!("Watching {path:?}");
72
73 let tx_clone = tx.clone();
74 let mut watcher = PollWatcher::with_initial_scan(
75 move |watch_event: notify::Result<Event>| {
76 if let Ok(event) = watch_event {
77 for path in event.paths {
78 if let Some(extension) = path.extension().and_then(OsStr::to_str) {
79 if ["csv", "log", "txt"].contains(&extension) {
80 let _ = tx.send(path.clone());
81 }
82 }
83 if let Some(filename) = path.file_name().and_then(OsStr::to_str) {
84 if filename == "amplitude.cache" {
85 let _ = tx.send(path);
86 }
87 }
88 }
89 }
90 },
91 Config::default()
92 .with_compare_contents(true)
93 .with_poll_interval(Duration::from_millis(millis)),
94 move |scan_event: notify::Result<PathBuf>| {
95 if let Ok(path) = scan_event {
96 if let Some(extension) = path.extension().and_then(OsStr::to_str) {
97 if ["csv", "log", "txt"].contains(&extension) {
98 let _ = tx_clone.send(path.clone());
99 }
100 }
101 if let Some(filename) = path.file_name().and_then(OsStr::to_str) {
102 if filename == "amplitude.cache" {
103 let _ = tx_clone.send(path);
104 }
105 }
106 }
107 },
108 )?;
109
110 watcher.watch(path, RecursiveMode::NonRecursive)?;
111
112 Ok(watcher)
113}
114
115pub fn launch_game(args: Args) -> Result<()> {
122 let args = args.collect::<Vec<_>>();
123 if args.len() > 1 {
124 let mut child = Command::new(&args[1])
125 .args(args.iter().skip(2))
126 .stderr(Stdio::null())
127 .stdout(Stdio::null())
128 .spawn()?;
129
130 std::thread::spawn(move || {
131 child.wait().unwrap();
132 std::process::exit(0);
133 });
134 }
135
136 Ok(())
137}
138
139pub async fn process_avatars(
142 settings: Settings,
143 (_tx, rx): (Sender<PathBuf>, Receiver<PathBuf>),
144) -> Result<()> {
145 #[cfg(feature = "cache")]
146 let cache = Cache::new().await?;
147 let providers = settings
148 .providers
149 .iter()
150 .filter_map(|provider| match provider {
151 #[cfg(feature = "cache")]
152 ProviderKind::CACHE => None,
153 #[cfg(feature = "avtrdb")]
154 ProviderKind::AVTRDB => provider!(AvtrDB::new(&settings)),
155 #[cfg(feature = "nsvr")]
156 ProviderKind::NSVR => provider!(NSVR::new(&settings)),
157 #[cfg(feature = "paw")]
158 ProviderKind::PAW => provider!(Paw::new(&settings)),
159 #[cfg(feature = "vrcdb")]
160 ProviderKind::VRCDB => provider!(VrcDB::new(&settings)),
161 #[cfg(feature = "vrcwb")]
162 ProviderKind::VRCWB => provider!(VrcWB::new(&settings)),
163 })
164 .collect::<Vec<_>>();
165
166 while let Ok(path) = rx.recv() {
167 for avatar_id in parse_avatar_ids(&path, settings.clear_amplitude) {
168 #[cfg(feature = "cache")] if !cache.check_avatar_id(&avatar_id).await? {
170 continue;
171 }
172
173 #[cfg(feature = "cache")] let mut send_to_cache = true;
175
176 print_colorized(&avatar_id);
177
178 let futures = providers
180 .iter()
181 .map(|provider| provider.send_avatar_id(&avatar_id));
182 let results = futures::future::join_all(futures).await;
183
184 for (provider, result) in providers.iter().zip(results) {
185 let kind = provider.kind();
186 match result {
187 Ok(unique) => {
188 if unique {
189 info!("^ Successfully Submitted to {kind}");
190 }
191 }
192 Err(error) => {
193 #[cfg(feature = "cache")]
194 {
195 send_to_cache = false;
196 }
197 error!("^ Failed to submit to {kind}: {error}");
198 }
199 }
200 }
201
202 #[cfg(feature = "cache")]
203 if send_to_cache {
204 cache.send_avatar_id(&avatar_id).await?;
205 }
206 }
207 }
208
209 bail!("Channel Closed")
210}
211
212pub fn parse_path_env(path: &str) -> Result<PathBuf, Error> {
218 let path = regex_replace_all!(r"(?:\$|%)(\w+)%?", path, |_, env| {
219 std::env::var(env).unwrap_or_else(|_| panic!("Environment Variable not found: {env}"))
220 });
221
222 let path = Path::new(path.as_ref());
223 if !path.exists() {
224 if let Some(parent) = path.parent() {
225 create_dir_all(parent)?;
226 }
227 File::create(path)?;
228 }
229
230 std::fs::canonicalize(path)
231}
232
233#[must_use]
234pub fn parse_avatar_ids(path: &PathBuf, clear_amplitude: bool) -> Vec<String> {
235 static RE: Lazy<Regex> = lazy_regex!(r"avtr_\w{8}-\w{4}-\w{4}-\w{4}-\w{12}");
236
237 let Ok(file) = File::open(path) else {
238 return Vec::new(); };
240
241 let mut reader = BufReader::new(file);
242 let mut avatar_ids = Vec::new();
243 let mut buf = Vec::new();
244
245 while reader.read_until(b'\n', &mut buf).unwrap_or(0) > 0 {
246 let line = String::from_utf8_lossy(&buf);
247 for mat in RE.find_iter(&line) {
248 avatar_ids.push(mat.as_str().to_string());
249 }
250 buf.clear();
251 }
252
253 drop(reader);
255
256 if clear_amplitude && path.file_name().and_then(|n| n.to_str()) == Some("amplitude.cache") {
258 match std::fs::write(path, "") {
259 Ok(()) => debug!("Cleared amplitude file: {path:?}"),
260 Err(error) => warn!("Failed to clear amplitude file: {error}"),
261 }
262 }
263
264 avatar_ids
265}
266
267pub fn print_colorized(avatar_id: &str) {
269 static INDEX: LazyLock<RwLock<usize>> = LazyLock::new(|| RwLock::new(0));
270 static COLORS: LazyLock<[Color; 12]> = LazyLock::new(|| {
271 [
272 Color::Red,
273 Color::BrightRed,
274 Color::Yellow,
275 Color::BrightYellow,
276 Color::Green,
277 Color::BrightGreen,
278 Color::Blue,
279 Color::BrightBlue,
280 Color::Cyan,
281 Color::BrightCyan,
282 Color::Magenta,
283 Color::BrightMagenta,
284 ]
285 });
286
287 let index = *INDEX.read();
288 let color = COLORS[index];
289 *INDEX.write() = (index + 1) % COLORS.len();
290
291 let text = format!("vrcx://avatar/{avatar_id}");
292 let link = Link::new(&text, &text).to_string().color(color);
293 info!("{link}");
294}