Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
//! Tools for loading a [`ChannelConfig`] from the external config generator binary.
|
||||
//!
|
||||
//! For non-bundled builds, the generator is invoked at runtime. For bundled builds, the config
|
||||
//! is embedded at compile time via the build script.
|
||||
use warp_core::channel::ChannelConfig;
|
||||
|
||||
/// The name of the config generator binary, expected to be on PATH.
|
||||
const CONFIG_BIN_NAME: &str = "warp-channel-config";
|
||||
|
||||
#[macro_export]
|
||||
#[cfg(windows)]
|
||||
macro_rules! path_concat {
|
||||
($path:expr, $file:expr) => {
|
||||
concat!($path, "\\", $file)
|
||||
};
|
||||
}
|
||||
#[macro_export]
|
||||
#[cfg(not(windows))]
|
||||
macro_rules! path_concat {
|
||||
($path:expr, $file:expr) => {
|
||||
concat!($path, "/", $file)
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! load_config {
|
||||
($channel:expr) => {{
|
||||
#[cfg(feature = "release_bundle")]
|
||||
{
|
||||
channel_config::load_config_from_embedded(include_str!($crate::path_concat!(
|
||||
env!("OUT_DIR"),
|
||||
concat!($channel, "_config.json")
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "release_bundle"))]
|
||||
{
|
||||
channel_config::load_config_from_generator($channel)
|
||||
}
|
||||
}};
|
||||
}
|
||||
pub use load_config;
|
||||
|
||||
/// Invokes the config generator binary at runtime and deserializes its JSON output into a
|
||||
/// [`ChannelConfig`].
|
||||
#[cfg_attr(feature = "release_bundle", expect(dead_code))]
|
||||
pub fn load_config_from_generator(channel: &str) -> ChannelConfig {
|
||||
let target_family = if cfg!(target_family = "wasm") {
|
||||
"wasm"
|
||||
} else {
|
||||
"native"
|
||||
};
|
||||
|
||||
let target_os = if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else {
|
||||
"linux"
|
||||
};
|
||||
|
||||
let output = command::blocking::Command::new(CONFIG_BIN_NAME)
|
||||
.arg("--channel")
|
||||
.arg(channel)
|
||||
.arg("--target-family")
|
||||
.arg(target_family)
|
||||
.arg("--target-os")
|
||||
.arg(target_os)
|
||||
.output()
|
||||
.unwrap_or_else(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
panic!(
|
||||
"\n\n'{CONFIG_BIN_NAME}' was not found on PATH.\n\n\
|
||||
To build internal channels, run:\n\
|
||||
\n\
|
||||
\x20 ./script/install_channel_config\n\n"
|
||||
)
|
||||
}
|
||||
panic!("Failed to execute '{CONFIG_BIN_NAME}': {err}")
|
||||
});
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
panic!("Config generator failed for channel '{channel}':\n{stderr}");
|
||||
}
|
||||
|
||||
serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
panic!("Failed to parse config generator output for channel '{channel}': {err}\nOutput:\n{stdout}")
|
||||
})
|
||||
}
|
||||
|
||||
/// Deserializes a [`ChannelConfig`] from a JSON string embedded at compile time.
|
||||
///
|
||||
/// This is used to load the channel configuration in release bundles, where configuration
|
||||
/// is embedded at compile time instead of being generated at runtime.
|
||||
#[cfg_attr(not(feature = "release_bundle"), expect(dead_code))]
|
||||
pub fn load_config_from_embedded(json: &str) -> ChannelConfig {
|
||||
serde_json::from_str(json)
|
||||
.unwrap_or_else(|err| panic!("Failed to parse embedded channel config: {err}"))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// On Windows, we don't want to display a console window when the application is running in release
|
||||
// builds. See https://doc.rust-lang.org/reference/runtime.html#the-windows_subsystem-attribute.
|
||||
#![cfg_attr(feature = "release_bundle", windows_subsystem = "windows")]
|
||||
|
||||
#[path = "channel_config.rs"]
|
||||
mod channel_config;
|
||||
|
||||
use anyhow::Result;
|
||||
use warp_core::{
|
||||
channel::{Channel, ChannelState},
|
||||
features,
|
||||
};
|
||||
|
||||
// Simple wrapper around warp::run() for dev channel builds.
|
||||
fn main() -> Result<()> {
|
||||
ChannelState::set(
|
||||
ChannelState::new(Channel::Dev, channel_config::load_config!("dev"))
|
||||
.with_additional_features(features::DEBUG_FLAGS)
|
||||
.with_additional_features(features::DOGFOOD_FLAGS)
|
||||
.with_additional_features(features::PREVIEW_FLAGS),
|
||||
);
|
||||
|
||||
warp::run()
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Generates a JSON Schema file describing Warp's user-facing settings.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```
|
||||
//! cargo run --bin generate_settings_schema -- [--channel dev|preview|stable] [output_path]
|
||||
//! ```
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
|
||||
use schemars::SchemaGenerator;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use settings::schema::SettingSchemaEntry;
|
||||
use warp_core::features::{FeatureFlag, DEBUG_FLAGS, DOGFOOD_FLAGS, PREVIEW_FLAGS, RELEASE_FLAGS};
|
||||
|
||||
/// Ensures all `inventory::submit!` registrations from the app crate's
|
||||
/// dependency tree are linked into the binary.
|
||||
///
|
||||
/// Binary targets only link crate code that is transitively referenced.
|
||||
/// Without an explicit reference to the `warp` library, the linker will
|
||||
/// not include most of the app's object files and the `inventory`
|
||||
/// submissions they contain.
|
||||
fn ensure_settings_linked() {
|
||||
let _ = std::hint::black_box(warp::settings::RESTORE_SESSION);
|
||||
}
|
||||
|
||||
/// Recursively strips `minimum`, `maximum`, and `format` from integer and
|
||||
/// number schemas. schemars derives these from Rust type bounds (e.g. `u8`
|
||||
/// → `minimum: 0, maximum: 255, format: "uint8"`), which are misleading
|
||||
/// for settings whose valid domain is narrower than the type allows.
|
||||
fn strip_numeric_metadata(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let is_numeric = map
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|t| t == "integer" || t == "number");
|
||||
|
||||
if is_numeric {
|
||||
map.remove("minimum");
|
||||
map.remove("maximum");
|
||||
map.remove("format");
|
||||
}
|
||||
|
||||
for val in map.values_mut() {
|
||||
strip_numeric_metadata(val);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for val in arr {
|
||||
strip_numeric_metadata(val);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes `{"enum": [], "type": "string"}` entries from `oneOf` arrays.
|
||||
/// schemars emits an empty enum bucket for externally-tagged enums when all
|
||||
/// unit variants have individual descriptions (and are therefore promoted to
|
||||
/// separate `oneOf` branches with `const`). The empty bucket is unreachable
|
||||
/// and confuses schema consumers.
|
||||
fn strip_empty_enum_entries(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::Array(one_of)) = map.get_mut("oneOf") {
|
||||
one_of.retain(|entry| {
|
||||
!matches!(entry, Value::Object(obj)
|
||||
if obj.get("enum").is_some_and(|e| e.as_array().is_some_and(|a| a.is_empty()))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
for val in map.values_mut() {
|
||||
strip_empty_enum_entries(val);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for val in arr {
|
||||
strip_empty_enum_entries(val);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn active_flags_for_channel(channel: &str) -> HashSet<FeatureFlag> {
|
||||
let mut flags = HashSet::new();
|
||||
|
||||
let flag_lists: &[&[FeatureFlag]] = match channel {
|
||||
"stable" => &[RELEASE_FLAGS],
|
||||
"preview" => &[RELEASE_FLAGS, PREVIEW_FLAGS],
|
||||
"dev" => &[RELEASE_FLAGS, PREVIEW_FLAGS, DOGFOOD_FLAGS, DEBUG_FLAGS],
|
||||
other => {
|
||||
eprintln!("Unknown channel '{other}', defaulting to dev");
|
||||
&[RELEASE_FLAGS, PREVIEW_FLAGS, DOGFOOD_FLAGS, DEBUG_FLAGS]
|
||||
}
|
||||
};
|
||||
|
||||
for list in flag_lists {
|
||||
for flag in *list {
|
||||
flags.insert(*flag);
|
||||
}
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
|
||||
/// Creates intermediate hierarchy objects so that a setting at e.g.
|
||||
/// `appearance.text` is nested under `properties.appearance.properties.text.properties`.
|
||||
fn ensure_hierarchy<'a>(
|
||||
root_properties: &'a mut Map<String, Value>,
|
||||
hierarchy: &str,
|
||||
) -> &'a mut Map<String, Value> {
|
||||
let segments: Vec<&str> = hierarchy.split('.').collect();
|
||||
let mut current = root_properties;
|
||||
|
||||
for segment in segments {
|
||||
// Ensure the segment object exists
|
||||
let entry = current.entry(segment.to_string()).or_insert_with(|| {
|
||||
Value::Object({
|
||||
let mut m = Map::new();
|
||||
m.insert("type".to_string(), Value::String("object".to_string()));
|
||||
m.insert("properties".to_string(), Value::Object(Map::new()));
|
||||
m
|
||||
})
|
||||
});
|
||||
|
||||
// Navigate into its properties
|
||||
current = entry
|
||||
.as_object_mut()
|
||||
.expect("hierarchy node should be an object")
|
||||
.entry("properties")
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()
|
||||
.expect("properties should be an object");
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
fn main() {
|
||||
ensure_settings_linked();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let mut channel = "dev";
|
||||
let mut output_path: Option<&str> = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--channel" => {
|
||||
i += 1;
|
||||
if i < args.len() {
|
||||
channel = &args[i];
|
||||
}
|
||||
}
|
||||
arg if !arg.starts_with('-') => {
|
||||
output_path = Some(arg);
|
||||
}
|
||||
other => {
|
||||
eprintln!("Unknown argument: {other}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let active_flags = active_flags_for_channel(channel);
|
||||
let mut generator = SchemaGenerator::default();
|
||||
let mut root_properties = Map::new();
|
||||
let mut entry_count = 0;
|
||||
|
||||
for entry in inventory::iter::<SettingSchemaEntry> {
|
||||
// Skip private settings
|
||||
if entry.is_private {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip settings whose feature flag is not active
|
||||
if let Some(flag) = entry.feature_flag {
|
||||
if !active_flags.contains(&flag) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let type_schema = (entry.schema_fn)(&mut generator);
|
||||
|
||||
let mut schema_value: Value = type_schema.to_value();
|
||||
|
||||
// Compute default value — prefer file default over serde default
|
||||
let default_json = (entry.file_default_value_fn)();
|
||||
|
||||
if let Ok(default_value) = serde_json::from_str::<Value>(&default_json) {
|
||||
if let Some(obj) = schema_value.as_object_mut() {
|
||||
obj.insert("default".to_string(), default_value);
|
||||
}
|
||||
}
|
||||
|
||||
// Always overwrite description with the macro-provided one
|
||||
if !entry.description.is_empty() {
|
||||
if let Some(obj) = schema_value.as_object_mut() {
|
||||
obj.insert(
|
||||
"description".to_string(),
|
||||
Value::String(entry.description.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Place the setting in the hierarchy
|
||||
let target = if let Some(hierarchy) = entry.hierarchy {
|
||||
ensure_hierarchy(&mut root_properties, hierarchy)
|
||||
} else {
|
||||
&mut root_properties
|
||||
};
|
||||
|
||||
target.insert(entry.storage_key.to_string(), schema_value);
|
||||
entry_count += 1;
|
||||
}
|
||||
|
||||
// Collect $defs from the generator
|
||||
let defs_map = generator.take_definitions(true);
|
||||
|
||||
// Assemble the root document
|
||||
let mut root = Map::new();
|
||||
root.insert(
|
||||
"$schema".to_string(),
|
||||
Value::String("https://json-schema.org/draft/2020-12/schema".to_string()),
|
||||
);
|
||||
root.insert(
|
||||
"title".to_string(),
|
||||
Value::String("Warp Settings".to_string()),
|
||||
);
|
||||
root.insert(
|
||||
"description".to_string(),
|
||||
Value::String(format!(
|
||||
"JSON Schema for Warp settings ({channel} channel, {entry_count} settings)"
|
||||
)),
|
||||
);
|
||||
root.insert("type".to_string(), Value::String("object".to_string()));
|
||||
root.insert("properties".to_string(), Value::Object(root_properties));
|
||||
|
||||
if !defs_map.is_empty() {
|
||||
root.insert("$defs".to_string(), Value::Object(defs_map));
|
||||
}
|
||||
|
||||
// Strip type-derived numeric metadata (minimum, maximum, format) that
|
||||
// schemars emits from Rust primitive bounds (e.g. u8 → max 255).
|
||||
// These leak implementation details rather than semantic constraints.
|
||||
let mut root_value = Value::Object(root);
|
||||
strip_numeric_metadata(&mut root_value);
|
||||
strip_empty_enum_entries(&mut root_value);
|
||||
|
||||
let output = serde_json::to_string_pretty(&root_value).expect("schema should serialize");
|
||||
|
||||
if let Some(path) = output_path {
|
||||
let mut file = std::fs::File::create(path)
|
||||
.unwrap_or_else(|e| panic!("Failed to create output file '{path}': {e}"));
|
||||
file.write_all(output.as_bytes())
|
||||
.unwrap_or_else(|e| panic!("Failed to write to '{path}': {e}"));
|
||||
eprintln!("Wrote {entry_count} settings to {path}");
|
||||
} else {
|
||||
println!("{output}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#[path = "channel_config.rs"]
|
||||
mod channel_config;
|
||||
|
||||
use anyhow::Result;
|
||||
use warp_core::{
|
||||
channel::{Channel, ChannelState},
|
||||
features,
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let config = channel_config::load_config!("local");
|
||||
|
||||
let mut state = ChannelState::new(Channel::Local, config)
|
||||
.with_additional_features(features::DEBUG_FLAGS)
|
||||
.with_additional_features(features::DOGFOOD_FLAGS)
|
||||
.with_additional_features(features::PREVIEW_FLAGS);
|
||||
|
||||
// Enable sandbox telemetry feature flag if the env var is set.
|
||||
if std::env::var("WITH_SANDBOX_TELEMETRY").is_ok() {
|
||||
state = state.with_additional_features(&[features::FeatureFlag::WithSandboxTelemetry]);
|
||||
}
|
||||
|
||||
ChannelState::set(state);
|
||||
|
||||
warp::run()
|
||||
}
|
||||
|
||||
// If we're not using an external plist, embed the following as the Info.plist.
|
||||
#[cfg(all(not(feature = "extern_plist"), target_os = "macos"))]
|
||||
embed_plist::embed_info_plist_bytes!(r#"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>WarpLocal</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>warp</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.warp.Warp-Local</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>WarpLocal</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array><dict><key>CFBundleURLName</key><string>Custom App</string><key>CFBundleURLSchemes</key><array><string>warplocal</string></array></dict></array>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Denver Technologies, Inc</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#.as_bytes());
|
||||
@@ -0,0 +1,69 @@
|
||||
// On Windows, we don't want to display a console window when the application is running in release
|
||||
// builds. See https://doc.rust-lang.org/reference/runtime.html#the-windows_subsystem-attribute.
|
||||
#![cfg_attr(feature = "release_bundle", windows_subsystem = "windows")]
|
||||
|
||||
use anyhow::Result;
|
||||
use warp_core::{
|
||||
channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig},
|
||||
AppId,
|
||||
};
|
||||
|
||||
// Simple wrapper around warp::run() for Warp OSS builds.
|
||||
fn main() -> Result<()> {
|
||||
let mut state = ChannelState::new(
|
||||
Channel::Oss,
|
||||
ChannelConfig {
|
||||
app_id: AppId::new("dev", "warp", "WarpOss"),
|
||||
logfile_name: "warp-oss.log".into(),
|
||||
server_config: WarpServerConfig::production(),
|
||||
oz_config: OzConfig::production(),
|
||||
telemetry_config: None,
|
||||
crash_reporting_config: None,
|
||||
autoupdate_config: None,
|
||||
mcp_static_config: None,
|
||||
},
|
||||
);
|
||||
if cfg!(debug_assertions) {
|
||||
state = state.with_additional_features(warp_core::features::DEBUG_FLAGS);
|
||||
}
|
||||
ChannelState::set(state);
|
||||
|
||||
warp::run()
|
||||
}
|
||||
|
||||
// If we're not using an external plist, embed the following as the Info.plist.
|
||||
#[cfg(all(not(feature = "extern_plist"), target_os = "macos"))]
|
||||
embed_plist::embed_info_plist_bytes!(r#"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>WarpOss</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>warp-oss</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.warp.WarpOss</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>WarpOss</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array><dict><key>CFBundleURLName</key><string>Custom App</string><key>CFBundleURLSchemes</key><array><string>warposs</string></array></dict></array>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Denver Technologies, Inc</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"#.as_bytes());
|
||||
@@ -0,0 +1,23 @@
|
||||
// On Windows, we don't want to display a console window when the application is running in release
|
||||
// builds. See https://doc.rust-lang.org/reference/runtime.html#the-windows_subsystem-attribute.
|
||||
#![cfg_attr(feature = "release_bundle", windows_subsystem = "windows")]
|
||||
|
||||
#[path = "channel_config.rs"]
|
||||
mod channel_config;
|
||||
|
||||
use anyhow::Result;
|
||||
use warp_core::{
|
||||
channel::{Channel, ChannelState},
|
||||
features,
|
||||
};
|
||||
|
||||
// Simple wrapper around warp::run() for feature preview channel builds.
|
||||
fn main() -> Result<()> {
|
||||
ChannelState::set(
|
||||
ChannelState::new(Channel::Preview, channel_config::load_config!("preview"))
|
||||
.with_additional_features(features::PREVIEW_FLAGS)
|
||||
.with_additional_features(&[features::FeatureFlag::ForceLogin]),
|
||||
);
|
||||
|
||||
warp::run()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// On Windows, we don't want to display a console window when the application is running in release
|
||||
// builds. See https://doc.rust-lang.org/reference/runtime.html#the-windows_subsystem-attribute.
|
||||
#![cfg_attr(feature = "release_bundle", windows_subsystem = "windows")]
|
||||
|
||||
#[path = "channel_config.rs"]
|
||||
mod channel_config;
|
||||
|
||||
use anyhow::Result;
|
||||
use warp_core::channel::{Channel, ChannelState};
|
||||
|
||||
// Simple wrapper around warp::run() for stable channel builds.
|
||||
fn main() -> Result<()> {
|
||||
ChannelState::set(ChannelState::new(
|
||||
Channel::Stable,
|
||||
channel_config::load_config!("stable"),
|
||||
));
|
||||
|
||||
warp::run()
|
||||
}
|
||||
Reference in New Issue
Block a user