mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-19 19:08:02 +02:00
feat: partially add wayfern
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WayfernConfig {
|
||||
#[serde(default)]
|
||||
pub fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub randomize_fingerprint_on_launch: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub os: Option<String>,
|
||||
#[serde(default)]
|
||||
pub screen_max_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub screen_max_height: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub screen_min_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub screen_min_height: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub geoip: Option<serde_json::Value>, // For compatibility with shared config form
|
||||
#[serde(default)]
|
||||
pub block_images: Option<bool>, // For compatibility with shared config form
|
||||
#[serde(default)]
|
||||
pub block_webrtc: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub block_webgl: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub executable_path: Option<String>,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct WayfernLaunchResult {
|
||||
pub id: String,
|
||||
#[serde(alias = "process_id")]
|
||||
pub processId: Option<u32>,
|
||||
#[serde(alias = "profile_path")]
|
||||
pub profilePath: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub cdp_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WayfernInstance {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
process_id: Option<u32>,
|
||||
profile_path: Option<String>,
|
||||
url: Option<String>,
|
||||
cdp_port: Option<u16>,
|
||||
}
|
||||
|
||||
struct WayfernManagerInner {
|
||||
instances: HashMap<String, WayfernInstance>,
|
||||
}
|
||||
|
||||
pub struct WayfernManager {
|
||||
inner: Arc<AsyncMutex<WayfernManagerInner>>,
|
||||
#[allow(dead_code)]
|
||||
base_dirs: BaseDirs,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CdpTarget {
|
||||
#[serde(rename = "type")]
|
||||
target_type: String,
|
||||
#[serde(rename = "webSocketDebuggerUrl")]
|
||||
websocket_debugger_url: Option<String>,
|
||||
}
|
||||
|
||||
impl WayfernManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
http_client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static WayfernManager {
|
||||
&WAYFERN_MANAGER
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
}
|
||||
|
||||
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn wait_for_cdp_ready(
|
||||
&self,
|
||||
port: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("http://127.0.0.1:{port}/json/version");
|
||||
let max_attempts = 50;
|
||||
let delay = Duration::from_millis(100);
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
match self.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
log::info!("CDP ready on port {port} after {attempt} attempts");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("CDP not ready after {max_attempts} attempts on port {port}").into())
|
||||
}
|
||||
|
||||
async fn get_cdp_targets(
|
||||
&self,
|
||||
port: u16,
|
||||
) -> Result<Vec<CdpTarget>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("http://127.0.0.1:{port}/json");
|
||||
let resp = self.http_client.get(&url).send().await?;
|
||||
let targets: Vec<CdpTarget> = resp.json().await?;
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
async fn send_cdp_command(
|
||||
&self,
|
||||
ws_url: &str,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (mut ws_stream, _) = connect_async(ws_url).await?;
|
||||
|
||||
let command = json!({
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params
|
||||
});
|
||||
|
||||
use futures_util::sink::SinkExt;
|
||||
use futures_util::stream::StreamExt;
|
||||
|
||||
ws_stream
|
||||
.send(Message::Text(command.to_string().into()))
|
||||
.await?;
|
||||
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg? {
|
||||
Message::Text(text) => {
|
||||
let response: serde_json::Value = serde_json::from_str(text.as_str())?;
|
||||
if response.get("id") == Some(&json!(1)) {
|
||||
if let Some(error) = response.get("error") {
|
||||
return Err(format!("CDP error: {}", error).into());
|
||||
}
|
||||
return Ok(response.get("result").cloned().unwrap_or(json!({})));
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err("No response received from CDP".into())
|
||||
}
|
||||
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
config: &WayfernConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
};
|
||||
|
||||
let port = Self::find_free_port().await?;
|
||||
log::info!("Launching headless Wayfern on port {port} for fingerprint generation");
|
||||
|
||||
let temp_profile_dir =
|
||||
std::env::temp_dir().join(format!("wayfern_fingerprint_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&temp_profile_dir)?;
|
||||
|
||||
let mut cmd = TokioCommand::new(&executable_path);
|
||||
cmd
|
||||
.arg("--headless=new")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--user-data-dir={}", temp_profile_dir.display()))
|
||||
.arg("--disable-gpu")
|
||||
.arg("--no-first-run")
|
||||
.arg("--no-default-browser-check")
|
||||
.arg("--disable-background-mode")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let child_id = child.id();
|
||||
|
||||
let cleanup = || async {
|
||||
if let Some(id) = child_id {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &id.to_string(), "/F"])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&temp_profile_dir);
|
||||
};
|
||||
|
||||
if let Err(e) = self.wait_for_cdp_ready(port).await {
|
||||
cleanup().await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let targets = match self.get_cdp_targets(port).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
cleanup().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let page_target = targets
|
||||
.iter()
|
||||
.find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some());
|
||||
|
||||
let ws_url = match page_target {
|
||||
Some(target) => target.websocket_debugger_url.as_ref().unwrap().clone(),
|
||||
None => {
|
||||
cleanup().await;
|
||||
return Err("No page target found for CDP".into());
|
||||
}
|
||||
};
|
||||
|
||||
let os = config
|
||||
.os
|
||||
.as_deref()
|
||||
.unwrap_or(if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
"windows"
|
||||
});
|
||||
|
||||
let refresh_result = self
|
||||
.send_cdp_command(
|
||||
&ws_url,
|
||||
"Wayfern.refreshFingerprint",
|
||||
json!({ "operatingSystem": os }),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = refresh_result {
|
||||
cleanup().await;
|
||||
return Err(format!("Failed to refresh fingerprint: {e}").into());
|
||||
}
|
||||
|
||||
let get_result = self
|
||||
.send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({}))
|
||||
.await;
|
||||
|
||||
let fingerprint = match get_result {
|
||||
Ok(result) => {
|
||||
// Wayfern.getFingerprint returns { fingerprint: {...} }
|
||||
// We need to extract just the fingerprint object
|
||||
result.get("fingerprint").cloned().unwrap_or(result)
|
||||
}
|
||||
Err(e) => {
|
||||
cleanup().await;
|
||||
return Err(format!("Failed to get fingerprint: {e}").into());
|
||||
}
|
||||
};
|
||||
|
||||
cleanup().await;
|
||||
|
||||
let fingerprint_json = serde_json::to_string(&fingerprint)
|
||||
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"Generated Wayfern fingerprint for OS: {}, fields: {:?}",
|
||||
os,
|
||||
fingerprint
|
||||
.as_object()
|
||||
.map(|o| o.keys().collect::<Vec<_>>())
|
||||
);
|
||||
Ok(fingerprint_json)
|
||||
}
|
||||
|
||||
pub async fn launch_wayfern(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
profile_path: &str,
|
||||
config: &WayfernConfig,
|
||||
url: Option<&str>,
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
};
|
||||
|
||||
let port = Self::find_free_port().await?;
|
||||
log::info!("Launching Wayfern on CDP port {port}");
|
||||
|
||||
let mut args = vec![
|
||||
format!("--remote-debugging-port={port}"),
|
||||
"--remote-debugging-address=127.0.0.1".to_string(),
|
||||
format!("--user-data-dir={}", profile_path),
|
||||
"--no-first-run".to_string(),
|
||||
"--no-default-browser-check".to_string(),
|
||||
"--disable-background-mode".to_string(),
|
||||
"--disable-component-update".to_string(),
|
||||
"--disable-background-timer-throttling".to_string(),
|
||||
"--crash-server-url=".to_string(),
|
||||
"--disable-updater".to_string(),
|
||||
"--disable-session-crashed-bubble".to_string(),
|
||||
"--hide-crash-restore-bubble".to_string(),
|
||||
"--disable-infobars".to_string(),
|
||||
"--disable-quic".to_string(),
|
||||
"--disable-features=DialMediaRouteProvider".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
];
|
||||
|
||||
if let Some(proxy) = proxy_url {
|
||||
args.push(format!("--proxy-server={proxy}"));
|
||||
}
|
||||
|
||||
if let Some(url) = url {
|
||||
args.push(url.to_string());
|
||||
}
|
||||
|
||||
let mut cmd = TokioCommand::new(&executable_path);
|
||||
cmd.args(&args);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let process_id = child.id();
|
||||
|
||||
self.wait_for_cdp_ready(port).await?;
|
||||
|
||||
if let Some(fingerprint_json) = &config.fingerprint {
|
||||
log::info!(
|
||||
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
|
||||
fingerprint_json.len()
|
||||
);
|
||||
|
||||
let stored_value: serde_json::Value = serde_json::from_str(fingerprint_json)
|
||||
.map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?;
|
||||
|
||||
// The stored fingerprint should be the fingerprint object directly (after our fix in generate_fingerprint_config)
|
||||
// But for backwards compatibility, also handle the wrapped format
|
||||
let fingerprint = if stored_value.get("fingerprint").is_some() {
|
||||
// Old format: {"fingerprint": {...}} - extract the inner fingerprint
|
||||
stored_value.get("fingerprint").cloned().unwrap()
|
||||
} else {
|
||||
// New format: fingerprint object directly {...}
|
||||
stored_value.clone()
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Fingerprint prepared for CDP command, fields: {:?}",
|
||||
fingerprint
|
||||
.as_object()
|
||||
.map(|o| o.keys().take(5).collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
let targets = self.get_cdp_targets(port).await?;
|
||||
log::info!("Found {} CDP targets", targets.len());
|
||||
|
||||
let page_targets: Vec<_> = targets.iter().filter(|t| t.target_type == "page").collect();
|
||||
log::info!(
|
||||
"Found {} page targets for fingerprint application",
|
||||
page_targets.len()
|
||||
);
|
||||
|
||||
for target in page_targets {
|
||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
|
||||
// Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped
|
||||
match self
|
||||
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint.clone())
|
||||
.await
|
||||
{
|
||||
Ok(result) => log::info!(
|
||||
"Successfully applied fingerprint to page target: {:?}",
|
||||
result
|
||||
),
|
||||
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("No fingerprint found in config, browser will use default fingerprint");
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let instance = WayfernInstance {
|
||||
id: id.clone(),
|
||||
process_id,
|
||||
profile_path: Some(profile_path.to_string()),
|
||||
url: url.map(|s| s.to_string()),
|
||||
cdp_port: Some(port),
|
||||
};
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.instances.insert(id.clone(), instance);
|
||||
|
||||
Ok(WayfernLaunchResult {
|
||||
id,
|
||||
processId: process_id,
|
||||
profilePath: Some(profile_path.to_string()),
|
||||
url: url.map(|s| s.to_string()),
|
||||
cdp_port: Some(port),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_wayfern(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
|
||||
if let Some(instance) = inner.instances.remove(id) {
|
||||
if let Some(pid) = instance.process_id {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output();
|
||||
}
|
||||
log::info!("Stopped Wayfern instance {id} (PID: {pid})");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
|
||||
// Find the instance with the matching profile path
|
||||
let mut found_id: Option<String> = None;
|
||||
for (id, instance) in &inner.instances {
|
||||
if let Some(path) = &instance.profile_path {
|
||||
if path == profile_path {
|
||||
found_id = Some(id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an instance, verify the process is still running
|
||||
if let Some(id) = found_id {
|
||||
if let Some(instance) = inner.instances.get(&id) {
|
||||
if let Some(pid) = instance.process_id {
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
|
||||
|
||||
if system.process(sysinfo_pid).is_some() {
|
||||
// Process is still running
|
||||
return Some(WayfernLaunchResult {
|
||||
id: id.clone(),
|
||||
processId: instance.process_id,
|
||||
profilePath: instance.profile_path.clone(),
|
||||
url: instance.url.clone(),
|
||||
cdp_port: instance.cdp_port,
|
||||
});
|
||||
} else {
|
||||
// Process has died (e.g., Cmd+Q), remove from instances
|
||||
log::info!(
|
||||
"Wayfern process {} for profile {} is no longer running, cleaning up",
|
||||
pid,
|
||||
profile_path
|
||||
);
|
||||
inner.instances.remove(&id);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn launch_wayfern_profile(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
config: &WayfernConfig,
|
||||
url: Option<&str>,
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
let profile_path_str = profile_path.to_string_lossy().to_string();
|
||||
|
||||
std::fs::create_dir_all(&profile_path)?;
|
||||
|
||||
if let Some(existing) = self.find_wayfern_by_profile(&profile_path_str).await {
|
||||
log::info!("Stopping existing Wayfern instance for profile");
|
||||
self.stop_wayfern(&existing.id).await?;
|
||||
}
|
||||
|
||||
self
|
||||
.launch_wayfern(
|
||||
app_handle,
|
||||
profile,
|
||||
&profile_path_str,
|
||||
config,
|
||||
url,
|
||||
proxy_url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn cleanup_dead_instances(&self) {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
let mut dead_ids = Vec::new();
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
for (id, instance) in &inner.instances {
|
||||
if let Some(pid) = instance.process_id {
|
||||
let pid = sysinfo::Pid::from_u32(pid);
|
||||
if !system.processes().contains_key(&pid) {
|
||||
dead_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id in dead_ids {
|
||||
log::info!("Cleaning up dead Wayfern instance: {id}");
|
||||
inner.instances.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
||||
}
|
||||
Reference in New Issue
Block a user