mirror of
https://github.com/bybrooklyn/openbitdo.git
synced 2026-03-19 12:12:57 -04:00
release prep: rc.1 baseline and gating updates
This commit is contained in:
@@ -2,11 +2,13 @@ use crate::command::CommandId;
|
||||
use crate::error::{BitdoError, BitdoErrorCode, Result};
|
||||
use crate::frame::{CommandFrame, ResponseFrame, ResponseStatus};
|
||||
use crate::profile::ProfileBlob;
|
||||
use crate::registry::{device_profile_for, find_command, find_pid, CommandRegistryRow};
|
||||
use crate::registry::{
|
||||
command_applies_to_pid, device_profile_for, find_command, find_pid, CommandRegistryRow,
|
||||
};
|
||||
use crate::transport::Transport;
|
||||
use crate::types::{
|
||||
CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence,
|
||||
SupportLevel, VidPid,
|
||||
CommandRuntimePolicy, DeviceProfile, EvidenceConfidence, PidCapability, ProtocolFamily,
|
||||
SafetyClass, SupportEvidence, SupportLevel, SupportTier, VidPid,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -83,15 +85,26 @@ pub struct CommandExecutionReport {
|
||||
pub struct DiagCommandStatus {
|
||||
pub command: CommandId,
|
||||
pub ok: bool,
|
||||
pub confidence: EvidenceConfidence,
|
||||
pub is_experimental: bool,
|
||||
pub severity: DiagSeverity,
|
||||
pub error_code: Option<BitdoErrorCode>,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DiagSeverity {
|
||||
Ok,
|
||||
Warning,
|
||||
NeedsAttention,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DiagProbeResult {
|
||||
pub target: VidPid,
|
||||
pub profile_name: String,
|
||||
pub support_level: SupportLevel,
|
||||
pub support_tier: SupportTier,
|
||||
pub protocol_family: ProtocolFamily,
|
||||
pub capability: PidCapability,
|
||||
pub evidence: SupportEvidence,
|
||||
@@ -104,6 +117,7 @@ pub struct IdentifyResult {
|
||||
pub target: VidPid,
|
||||
pub profile_name: String,
|
||||
pub support_level: SupportLevel,
|
||||
pub support_tier: SupportTier,
|
||||
pub protocol_family: ProtocolFamily,
|
||||
pub capability: PidCapability,
|
||||
pub evidence: SupportEvidence,
|
||||
@@ -187,6 +201,7 @@ impl<T: Transport> DeviceSession<T> {
|
||||
target: self.target,
|
||||
profile_name: profile.name,
|
||||
support_level: profile.support_level,
|
||||
support_tier: profile.support_tier,
|
||||
protocol_family: profile.protocol_family,
|
||||
capability: profile.capability,
|
||||
evidence: profile.evidence,
|
||||
@@ -195,33 +210,66 @@ impl<T: Transport> DeviceSession<T> {
|
||||
}
|
||||
|
||||
pub fn diag_probe(&mut self) -> DiagProbeResult {
|
||||
let checks = [
|
||||
let target_pid = self.target.pid;
|
||||
let checks_to_run = [
|
||||
CommandId::GetPid,
|
||||
CommandId::GetReportRevision,
|
||||
CommandId::GetMode,
|
||||
CommandId::GetControllerVersion,
|
||||
// Inferred safe reads are intentionally included in diagnostics so
|
||||
// users always see signal quality, but results are labeled
|
||||
// experimental and only strict safety conditions escalate.
|
||||
CommandId::GetSuperButton,
|
||||
CommandId::ReadProfile,
|
||||
]
|
||||
.iter()
|
||||
.map(|cmd| match self.send_command(*cmd, None) {
|
||||
Ok(_) => DiagCommandStatus {
|
||||
command: *cmd,
|
||||
ok: true,
|
||||
error_code: None,
|
||||
detail: "ok".to_owned(),
|
||||
},
|
||||
Err(err) => DiagCommandStatus {
|
||||
command: *cmd,
|
||||
ok: false,
|
||||
error_code: Some(err.code()),
|
||||
detail: err.to_string(),
|
||||
},
|
||||
.filter_map(|cmd| {
|
||||
let row = find_command(*cmd)?;
|
||||
if row.safety_class != SafetyClass::SafeRead {
|
||||
return None;
|
||||
}
|
||||
if !command_applies_to_pid(row, target_pid) {
|
||||
return None;
|
||||
}
|
||||
Some((*cmd, row.runtime_policy(), row.evidence_confidence()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut checks = Vec::with_capacity(checks_to_run.len());
|
||||
for (cmd, runtime_policy, confidence) in checks_to_run {
|
||||
match self.send_command(cmd, None) {
|
||||
Ok(_) => checks.push(DiagCommandStatus {
|
||||
command: cmd,
|
||||
ok: true,
|
||||
confidence,
|
||||
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
|
||||
severity: DiagSeverity::Ok,
|
||||
error_code: None,
|
||||
detail: "ok".to_owned(),
|
||||
}),
|
||||
Err(err) => checks.push(DiagCommandStatus {
|
||||
command: cmd,
|
||||
ok: false,
|
||||
confidence,
|
||||
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
|
||||
severity: classify_diag_failure(
|
||||
cmd,
|
||||
runtime_policy,
|
||||
confidence,
|
||||
err.code(),
|
||||
self.target.pid,
|
||||
),
|
||||
error_code: Some(err.code()),
|
||||
detail: err.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
DiagProbeResult {
|
||||
target: self.target,
|
||||
profile_name: self.profile.name.clone(),
|
||||
support_level: self.profile.support_level,
|
||||
support_tier: self.profile.support_tier,
|
||||
protocol_family: self.profile.protocol_family,
|
||||
capability: self.profile.capability,
|
||||
evidence: self.profile.evidence,
|
||||
@@ -290,6 +338,116 @@ impl<T: Transport> DeviceSession<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn jp108_read_dedicated_mappings(&mut self) -> Result<Vec<(u8, u16)>> {
|
||||
let resp = self.send_command(CommandId::Jp108ReadDedicatedMappings, None)?;
|
||||
Ok(parse_indexed_u16_table(&resp.raw, 10))
|
||||
}
|
||||
|
||||
pub fn jp108_write_dedicated_mapping(
|
||||
&mut self,
|
||||
index: u8,
|
||||
target_hid_usage: u16,
|
||||
) -> Result<()> {
|
||||
let row = self.ensure_command_allowed(CommandId::Jp108WriteDedicatedMapping)?;
|
||||
let mut payload = row.request.to_vec();
|
||||
if payload.len() < 7 {
|
||||
return Err(BitdoError::InvalidInput(
|
||||
"Jp108WriteDedicatedMapping payload shorter than expected".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
payload[4] = index;
|
||||
let usage = target_hid_usage.to_le_bytes();
|
||||
payload[5] = usage[0];
|
||||
payload[6] = usage[1];
|
||||
self.send_row(row, Some(&payload))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn u2_get_current_slot(&mut self) -> Result<u8> {
|
||||
let resp = self.send_command(CommandId::U2GetCurrentSlot, None)?;
|
||||
Ok(resp.parsed_fields.get("slot").copied().unwrap_or(0) as u8)
|
||||
}
|
||||
|
||||
pub fn u2_read_config_slot(&mut self, slot: u8) -> Result<Vec<u8>> {
|
||||
let row = self.ensure_command_allowed(CommandId::U2ReadConfigSlot)?;
|
||||
let mut payload = row.request.to_vec();
|
||||
if payload.len() > 4 {
|
||||
payload[4] = slot;
|
||||
}
|
||||
let resp = self.send_row(row, Some(&payload))?;
|
||||
Ok(resp.raw)
|
||||
}
|
||||
|
||||
pub fn u2_write_config_slot(&mut self, slot: u8, config_blob: &[u8]) -> Result<()> {
|
||||
let row = self.ensure_command_allowed(CommandId::U2WriteConfigSlot)?;
|
||||
let mut payload = row.request.to_vec();
|
||||
if payload.len() < 8 {
|
||||
return Err(BitdoError::InvalidInput(
|
||||
"U2WriteConfigSlot payload shorter than expected".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
payload[4] = slot;
|
||||
let copy_len = config_blob.len().min(payload.len().saturating_sub(8));
|
||||
if copy_len > 0 {
|
||||
payload[8..8 + copy_len].copy_from_slice(&config_blob[..copy_len]);
|
||||
}
|
||||
|
||||
self.send_row(row, Some(&payload))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn u2_read_button_map(&mut self, slot: u8) -> Result<Vec<(u8, u16)>> {
|
||||
let row = self.ensure_command_allowed(CommandId::U2ReadButtonMap)?;
|
||||
let mut payload = row.request.to_vec();
|
||||
if payload.len() > 4 {
|
||||
payload[4] = slot;
|
||||
}
|
||||
let resp = self.send_row(row, Some(&payload))?;
|
||||
Ok(parse_indexed_u16_table(&resp.raw, 17))
|
||||
}
|
||||
|
||||
pub fn u2_write_button_map(&mut self, slot: u8, mappings: &[(u8, u16)]) -> Result<()> {
|
||||
let row = self.ensure_command_allowed(CommandId::U2WriteButtonMap)?;
|
||||
let mut payload = row.request.to_vec();
|
||||
if payload.len() < 8 {
|
||||
return Err(BitdoError::InvalidInput(
|
||||
"U2WriteButtonMap payload shorter than expected".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
payload[4] = slot;
|
||||
for (index, usage) in mappings {
|
||||
let pos = 8usize.saturating_add((*index as usize).saturating_mul(2));
|
||||
if pos + 1 < payload.len() {
|
||||
let bytes = usage.to_le_bytes();
|
||||
payload[pos] = bytes[0];
|
||||
payload[pos + 1] = bytes[1];
|
||||
}
|
||||
}
|
||||
|
||||
self.send_row(row, Some(&payload))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn u2_set_mode(&mut self, mode: u8) -> Result<ModeState> {
|
||||
let row = self.ensure_command_allowed(CommandId::U2SetMode)?;
|
||||
let mut payload = row.request.to_vec();
|
||||
if payload.len() < 5 {
|
||||
return Err(BitdoError::InvalidInput(
|
||||
"U2SetMode payload shorter than expected".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
payload[4] = mode;
|
||||
self.send_row(row, Some(&payload))?;
|
||||
Ok(ModeState {
|
||||
mode,
|
||||
source: "U2SetMode".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn enter_bootloader(&mut self) -> Result<()> {
|
||||
self.send_command(CommandId::EnterBootloaderA, None)?;
|
||||
self.send_command(CommandId::EnterBootloaderB, None)?;
|
||||
@@ -529,12 +687,28 @@ impl<T: Transport> DeviceSession<T> {
|
||||
fn ensure_command_allowed(&self, command: CommandId) -> Result<&'static CommandRegistryRow> {
|
||||
let row = find_command(command).ok_or(BitdoError::UnknownCommand(command))?;
|
||||
|
||||
if row.confidence == CommandConfidence::Inferred && !self.config.experimental {
|
||||
return Err(BitdoError::ExperimentalRequired { command });
|
||||
// Gate 1: confidence/runtime policy.
|
||||
// We intentionally keep inferred write/unsafe paths non-executable until
|
||||
// they are upgraded to confirmed evidence.
|
||||
match row.runtime_policy() {
|
||||
CommandRuntimePolicy::EnabledDefault => {}
|
||||
CommandRuntimePolicy::ExperimentalGate => {
|
||||
if !self.config.experimental {
|
||||
return Err(BitdoError::ExperimentalRequired { command });
|
||||
}
|
||||
}
|
||||
CommandRuntimePolicy::BlockedUntilConfirmed => {
|
||||
return Err(BitdoError::UnsupportedForPid {
|
||||
command,
|
||||
pid: self.target.pid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Gate 2: PID/family/capability applicability.
|
||||
if !is_command_allowed_by_family(self.profile.protocol_family, command)
|
||||
|| !is_command_allowed_by_capability(self.profile.capability, command)
|
||||
|| !command_applies_to_pid(row, self.target.pid)
|
||||
{
|
||||
return Err(BitdoError::UnsupportedForPid {
|
||||
command,
|
||||
@@ -542,8 +716,19 @@ impl<T: Transport> DeviceSession<T> {
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 3: support-tier restrictions.
|
||||
if self.profile.support_tier == SupportTier::CandidateReadOnly
|
||||
&& !is_command_allowed_for_candidate_pid(self.target.pid, command, row.safety_class)
|
||||
{
|
||||
return Err(BitdoError::UnsupportedForPid {
|
||||
command,
|
||||
pid: self.target.pid,
|
||||
});
|
||||
}
|
||||
|
||||
// Gate 4: explicit unsafe confirmation requirements.
|
||||
if row.safety_class.is_unsafe() {
|
||||
if self.profile.support_level != SupportLevel::Full {
|
||||
if self.profile.support_tier != SupportTier::Full {
|
||||
return Err(BitdoError::UnsupportedForPid {
|
||||
command,
|
||||
pid: self.target.pid,
|
||||
@@ -555,7 +740,7 @@ impl<T: Transport> DeviceSession<T> {
|
||||
}
|
||||
|
||||
if row.safety_class == SafetyClass::SafeWrite
|
||||
&& self.profile.support_level == SupportLevel::DetectOnly
|
||||
&& self.profile.support_tier != SupportTier::Full
|
||||
{
|
||||
return Err(BitdoError::UnsupportedForPid {
|
||||
command,
|
||||
@@ -567,6 +752,83 @@ impl<T: Transport> DeviceSession<T> {
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_diag_failure(
|
||||
command: CommandId,
|
||||
runtime_policy: CommandRuntimePolicy,
|
||||
confidence: EvidenceConfidence,
|
||||
code: BitdoErrorCode,
|
||||
pid: u16,
|
||||
) -> DiagSeverity {
|
||||
if runtime_policy != CommandRuntimePolicy::ExperimentalGate
|
||||
|| confidence != EvidenceConfidence::Inferred
|
||||
{
|
||||
return DiagSeverity::Warning;
|
||||
}
|
||||
|
||||
// Escalation is intentionally narrow for inferred checks:
|
||||
// - identity mismatch / impossible transitions
|
||||
// - command/schema applicability mismatch
|
||||
// - precondition/capability mismatches implied by unsupported errors
|
||||
let identity_or_transition_issue = matches!(
|
||||
(command, code),
|
||||
(CommandId::GetPid, BitdoErrorCode::InvalidResponse)
|
||||
| (CommandId::GetPid, BitdoErrorCode::MalformedResponse)
|
||||
| (CommandId::GetMode, BitdoErrorCode::InvalidResponse)
|
||||
| (CommandId::GetModeAlt, BitdoErrorCode::InvalidResponse)
|
||||
| (CommandId::ReadProfile, BitdoErrorCode::InvalidResponse)
|
||||
| (
|
||||
CommandId::GetControllerVersion,
|
||||
BitdoErrorCode::InvalidResponse
|
||||
)
|
||||
| (CommandId::Version, BitdoErrorCode::InvalidResponse)
|
||||
);
|
||||
if identity_or_transition_issue {
|
||||
return DiagSeverity::NeedsAttention;
|
||||
}
|
||||
|
||||
if code == BitdoErrorCode::UnsupportedForPid
|
||||
&& find_command(command)
|
||||
.map(|row| command_applies_to_pid(row, pid))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return DiagSeverity::NeedsAttention;
|
||||
}
|
||||
|
||||
DiagSeverity::Warning
|
||||
}
|
||||
|
||||
fn is_command_allowed_for_candidate_pid(pid: u16, command: CommandId, safety: SafetyClass) -> bool {
|
||||
if safety != SafetyClass::SafeRead {
|
||||
return false;
|
||||
}
|
||||
|
||||
const BASE_DIAG_READS: &[CommandId] = &[
|
||||
CommandId::GetPid,
|
||||
CommandId::GetReportRevision,
|
||||
CommandId::GetControllerVersion,
|
||||
CommandId::Version,
|
||||
CommandId::Idle,
|
||||
];
|
||||
const STANDARD_CANDIDATE_PIDS: &[u16] = &[
|
||||
0x6002, 0x6003, 0x3010, 0x3011, 0x3012, 0x3013, 0x3004, 0x3019, 0x3100, 0x3105, 0x2100,
|
||||
0x2101, 0x901a, 0x6006, 0x5203, 0x5204, 0x301a, 0x9028, 0x3026, 0x3027,
|
||||
];
|
||||
const JP_CANDIDATE_PIDS: &[u16] = &[0x5200, 0x5201, 0x203a, 0x2049, 0x2028, 0x202e];
|
||||
|
||||
if BASE_DIAG_READS.contains(&command) {
|
||||
return STANDARD_CANDIDATE_PIDS.contains(&pid) || JP_CANDIDATE_PIDS.contains(&pid);
|
||||
}
|
||||
|
||||
if STANDARD_CANDIDATE_PIDS.contains(&pid) {
|
||||
return matches!(
|
||||
command,
|
||||
CommandId::GetMode | CommandId::GetModeAlt | CommandId::ReadProfile
|
||||
);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_command_allowed_by_capability(cap: PidCapability, command: CommandId) -> bool {
|
||||
match command {
|
||||
CommandId::GetPid
|
||||
@@ -580,8 +842,29 @@ fn is_command_allowed_by_capability(cap: PidCapability, command: CommandId) -> b
|
||||
CommandId::EnterBootloaderA
|
||||
| CommandId::EnterBootloaderB
|
||||
| CommandId::EnterBootloaderC
|
||||
| CommandId::ExitBootloader => cap.supports_boot,
|
||||
CommandId::FirmwareChunk | CommandId::FirmwareCommit => cap.supports_firmware,
|
||||
| CommandId::ExitBootloader
|
||||
| CommandId::Jp108EnterBootloader
|
||||
| CommandId::Jp108ExitBootloader
|
||||
| CommandId::U2EnterBootloader
|
||||
| CommandId::U2ExitBootloader => cap.supports_boot,
|
||||
CommandId::FirmwareChunk
|
||||
| CommandId::FirmwareCommit
|
||||
| CommandId::Jp108FirmwareChunk
|
||||
| CommandId::Jp108FirmwareCommit
|
||||
| CommandId::U2FirmwareChunk
|
||||
| CommandId::U2FirmwareCommit => cap.supports_firmware,
|
||||
CommandId::Jp108ReadDedicatedMappings
|
||||
| CommandId::Jp108WriteDedicatedMapping
|
||||
| CommandId::Jp108ReadFeatureFlags
|
||||
| CommandId::Jp108WriteFeatureFlags
|
||||
| CommandId::Jp108ReadVoice
|
||||
| CommandId::Jp108WriteVoice => cap.supports_jp108_dedicated_map,
|
||||
CommandId::U2GetCurrentSlot
|
||||
| CommandId::U2ReadConfigSlot
|
||||
| CommandId::U2WriteConfigSlot => cap.supports_u2_slot_config,
|
||||
CommandId::U2ReadButtonMap | CommandId::U2WriteButtonMap | CommandId::U2SetMode => {
|
||||
cap.supports_u2_button_map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +885,16 @@ fn is_command_allowed_by_family(family: ProtocolFamily, command: CommandId) -> b
|
||||
| CommandId::WriteProfile
|
||||
| CommandId::FirmwareChunk
|
||||
| CommandId::FirmwareCommit
|
||||
| CommandId::U2GetCurrentSlot
|
||||
| CommandId::U2ReadConfigSlot
|
||||
| CommandId::U2WriteConfigSlot
|
||||
| CommandId::U2ReadButtonMap
|
||||
| CommandId::U2WriteButtonMap
|
||||
| CommandId::U2SetMode
|
||||
| CommandId::U2EnterBootloader
|
||||
| CommandId::U2FirmwareChunk
|
||||
| CommandId::U2FirmwareCommit
|
||||
| CommandId::U2ExitBootloader
|
||||
),
|
||||
ProtocolFamily::DS4Boot => matches!(
|
||||
command,
|
||||
@@ -653,6 +946,21 @@ pub fn validate_response(command: CommandId, response: &[u8]) -> ResponseStatus
|
||||
ResponseStatus::Invalid
|
||||
}
|
||||
}
|
||||
CommandId::Jp108ReadDedicatedMappings
|
||||
| CommandId::Jp108ReadFeatureFlags
|
||||
| CommandId::Jp108ReadVoice
|
||||
| CommandId::U2ReadConfigSlot
|
||||
| CommandId::U2ReadButtonMap
|
||||
| CommandId::U2GetCurrentSlot => {
|
||||
if response.len() < 6 {
|
||||
return ResponseStatus::Malformed;
|
||||
}
|
||||
if response[0] == 0x02 && response[1] == 0x05 {
|
||||
ResponseStatus::Ok
|
||||
} else {
|
||||
ResponseStatus::Invalid
|
||||
}
|
||||
}
|
||||
CommandId::GetControllerVersion | CommandId::Version => {
|
||||
if response.len() < 5 {
|
||||
return ResponseStatus::Malformed;
|
||||
@@ -689,6 +997,12 @@ fn minimum_response_len(command: CommandId) -> usize {
|
||||
CommandId::GetPid => 24,
|
||||
CommandId::GetReportRevision => 6,
|
||||
CommandId::GetMode | CommandId::GetModeAlt => 6,
|
||||
CommandId::U2GetCurrentSlot => 6,
|
||||
CommandId::Jp108ReadDedicatedMappings
|
||||
| CommandId::Jp108ReadFeatureFlags
|
||||
| CommandId::Jp108ReadVoice
|
||||
| CommandId::U2ReadConfigSlot
|
||||
| CommandId::U2ReadButtonMap => 12,
|
||||
CommandId::GetControllerVersion | CommandId::Version => 5,
|
||||
_ => 2,
|
||||
}
|
||||
@@ -709,7 +1023,27 @@ fn parse_fields(command: CommandId, response: &[u8]) -> BTreeMap<String, u32> {
|
||||
parsed.insert("version_x100".to_owned(), fw);
|
||||
parsed.insert("beta".to_owned(), response[4] as u32);
|
||||
}
|
||||
CommandId::U2GetCurrentSlot if response.len() >= 6 => {
|
||||
parsed.insert("slot".to_owned(), response[5] as u32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn parse_indexed_u16_table(raw: &[u8], expected_items: usize) -> Vec<(u8, u16)> {
|
||||
let mut out = Vec::with_capacity(expected_items);
|
||||
let offset = if raw.len() >= 8 { 8 } else { 2 };
|
||||
|
||||
for idx in 0..expected_items {
|
||||
let pos = offset + idx * 2;
|
||||
let usage = if pos + 1 < raw.len() {
|
||||
u16::from_le_bytes([raw[pos], raw[pos + 1]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out.push((idx as u8, usage));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user