mirror of
https://github.com/italicsjenga/valence.git
synced 2024-12-23 22:41:30 +11:00
Packet inspector gui (#238)
Fixes #235 ## Description A GUI Project for the packet inspector ## Test Plan Steps: 1. Start (any) server 2. Start the proxy `cargo r -r -p packet_inspector_gui -- 127.0.0.1:25560 127.0.0.1:25565` 3. Join server (127.0.0.1:25560) 4. Magic
This commit is contained in:
parent
52fadb6dd8
commit
491e3a61d7
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
@ -38,6 +38,9 @@ jobs:
|
|||
restore-keys: |
|
||||
${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.toml') }}
|
||||
${{ runner.os }}-cargo-target
|
||||
- name: Install Dependencies (Linux)
|
||||
run: sudo apt-get update && sudo apt-get install -y libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libclang-dev libgtk-3-dev
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
- name: Validate formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Validate documentation
|
||||
|
|
|
@ -4,11 +4,33 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
description = "A simple Minecraft proxy for inspecting packets."
|
||||
|
||||
[features]
|
||||
default = ["syntax_highlighting"]
|
||||
syntax_highlighting = ["syntect"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4.0.30", features = ["derive"] }
|
||||
owo-colors = "3.5.0"
|
||||
regex = "1.6.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing-subscriber = "0.3.16"
|
||||
valence_protocol = { path = "../valence_protocol", version = "0.1.0", features = ["compression"] }
|
||||
valence_protocol = { path = "../valence_protocol", version = "0.1.0", features = [
|
||||
"compression",
|
||||
] }
|
||||
|
||||
egui = "0.21.0"
|
||||
eframe = { version = "0.21.0", default-features = false, features = [
|
||||
"default_fonts", # Embed the default egui fonts.
|
||||
"glow", # Use the glow rendering backend. Alternative: "wgpu".
|
||||
] }
|
||||
enum-map = "2.4.2"
|
||||
syntect = { version = "5", optional = true, default-features = false, features = [
|
||||
"default-fancy",
|
||||
] }
|
||||
time = { version = "0.3.17", features = ["local-offset"] }
|
||||
rfd = "0.11"
|
||||
owo-colors = "3.5.0"
|
||||
atty = "0.2"
|
||||
directories = "4.0"
|
||||
serde = { version = "1.0.152", features = ["derive"] }
|
||||
toml = "0.7.2"
|
||||
|
|
|
@ -1,44 +1,87 @@
|
|||
# What's This?
|
||||
|
||||
The packet inspector is a very simple Minecraft proxy for viewing the contents of packets as they are sent/received.
|
||||
It uses Valence's protocol facilities to print packet contents.
|
||||
This was made for three purposes:
|
||||
The packet inspector is a Minecraft proxy for viewing the contents of packets as
|
||||
they are sent/received. It uses Valence's protocol facilities to display packet
|
||||
contents. This was made for three purposes:
|
||||
|
||||
- Check that packets between Valence and client are matching your expectations.
|
||||
- Check that packets between vanilla server and client are parsed correctly by Valence.
|
||||
- Check that packets between vanilla server and client are parsed correctly by
|
||||
Valence.
|
||||
- Understand how the protocol works between the vanilla server and client.
|
||||
|
||||
# Usage
|
||||
|
||||
First, start a server
|
||||
Firstly, we should have a server running that we're going to be
|
||||
proxying/inspecting.
|
||||
|
||||
```sh
|
||||
cargo r -r --example conway
|
||||
```
|
||||
|
||||
In a separate terminal, start the packet inspector.
|
||||
Next up, we need to run the proxy server, this can be done in 2 different ways,
|
||||
either using the GUI application (default) or using the `--nogui` flag to log
|
||||
the packets to a terminal instance.
|
||||
|
||||
```sh
|
||||
cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565
|
||||
To assist, `--help` will produce the following:
|
||||
|
||||
```
|
||||
A simple Minecraft proxy for inspecting packets.
|
||||
|
||||
Usage: packet_inspector [OPTIONS] [CLIENT_ADDR] [SERVER_ADDR]
|
||||
|
||||
Arguments:
|
||||
[CLIENT_ADDR] The socket address to listen for connections on. This is the address clients should connect to
|
||||
[SERVER_ADDR] The socket address the proxy will connect to. This is the address of the server
|
||||
|
||||
Options:
|
||||
-m, --max-connections <MAX_CONNECTIONS>
|
||||
The maximum number of connections allowed to the proxy. By default, there is no limit
|
||||
--nogui
|
||||
Disable the GUI. Logging to stdout
|
||||
-i, --include-filter <INCLUDE_FILTER>
|
||||
Only show packets that match the filter
|
||||
-e, --exclude-filter <EXCLUDE_FILTER>
|
||||
Hide packets that match the filter. Note: Only in effect if nogui is set
|
||||
-h, --help
|
||||
Print help
|
||||
-V, --version
|
||||
Print version
|
||||
```
|
||||
|
||||
The client must connect to `localhost:25566`. You should see the packets in `stdout`.
|
||||
To launch in a Gui environment, simply launch `packet_inspector[.exe]` (or
|
||||
`cargo r -r -p packet_inspector` to run from source). The gui will prompt you
|
||||
for the `CLIENT_ADDR` and `SERVER_ADDR` if they have not been supplied via the
|
||||
command line arguments.
|
||||
|
||||
The `-i` and `-e` flags accept a regex to filter packets according to their name. The `-i` regex includes matching
|
||||
packets while the `-e` regex excludes matching packets.
|
||||
In a terminal only environment, use the `--nogui` option and supply
|
||||
`CLIENT_ADDR` and `SERVER_ADDR` as arguments.
|
||||
|
||||
For instance, if you only want to print the packets `Foo`, `Bar`, and `Baz`, you can use a regex such
|
||||
as `^(Foo|Bar|Baz)$` with the `-i` flag.
|
||||
|
||||
```sh
|
||||
cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565 -i '^(Foo|Bar|Baz)$'
|
||||
```bash
|
||||
cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565
|
||||
```
|
||||
|
||||
Packets are printed to `stdout` while errors are printed to `stderr`. If you only want to see errors in your terminal,
|
||||
direct `stdout` elsewhere.
|
||||
The client must connect to `localhost:25566`. You should see the packets in
|
||||
`stdout` when running in `--nogui`, or you should see packets streaming in on
|
||||
the Gui.
|
||||
|
||||
The `-i` and `-e` flags accept a regex to filter packets according to their
|
||||
name. The `-i` regex includes matching packets while the `-e` regex excludes
|
||||
matching packets. Do note that `-e` only applies in `--nogui` environment, as
|
||||
the Gui has a "packet selector" to enable/disable packets dynamically. The `-i`
|
||||
parameter value will be included in the `Filter` input field on the Gui.
|
||||
|
||||
For instance, if you only want to print the packets `Foo`, `Bar`, and `Baz`, you
|
||||
can use a regex such as `^(Foo|Bar|Baz)$` with the `-i` flag.
|
||||
|
||||
```sh
|
||||
cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565 > log.txt
|
||||
cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565 -i '^(Foo|Bar|Baz)$'
|
||||
```
|
||||
|
||||
Packets are printed to `stdout` while errors are printed to `stderr`. If you
|
||||
only want to see errors in your terminal, direct `stdout` elsewhere.
|
||||
|
||||
```sh
|
||||
cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565 > log.txt
|
||||
```
|
||||
|
||||
## Quick start with Vanilla Server via Docker
|
||||
|
@ -64,7 +107,7 @@ docker exec -i mc rcon-cli
|
|||
In a separate terminal, start the packet inspector.
|
||||
|
||||
```sh
|
||||
cargo r -r -p packet_inspector -- 127.0.0.1:25566 127.0.0.1:25565
|
||||
cargo r -r -p packet_inspector -- --nogui 127.0.0.1:25566 127.0.0.1:25565
|
||||
```
|
||||
|
||||
Open Minecraft and connect to `localhost:25566`.
|
||||
|
|
131
crates/packet_inspector/src/config.rs
Normal file
131
crates/packet_inspector/src/config.rs
Normal file
|
@ -0,0 +1,131 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::MetaPacket;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ApplicationConfig {
|
||||
server_addr: SocketAddr,
|
||||
client_addr: SocketAddr,
|
||||
max_connections: Option<usize>,
|
||||
filter: Option<String>,
|
||||
selected_packets: Option<BTreeMap<MetaPacket, bool>>,
|
||||
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
must_save: bool,
|
||||
}
|
||||
|
||||
impl Default for ApplicationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_addr: "127.0.0.1:25565".parse().unwrap(),
|
||||
client_addr: "127.0.0.1:25566".parse().unwrap(),
|
||||
max_connections: None,
|
||||
filter: None,
|
||||
selected_packets: None,
|
||||
must_save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationConfig {
|
||||
pub fn load() -> Result<ApplicationConfig, Box<dyn std::error::Error>> {
|
||||
let config_dir = get_or_create_project_dirs()?;
|
||||
|
||||
let config_file = config_dir.join("config.toml");
|
||||
|
||||
if config_file.exists() {
|
||||
let config = std::fs::read_to_string(config_file)?;
|
||||
|
||||
toml::from_str(&config).map_err(|e| e.into())
|
||||
} else {
|
||||
let config = toml::to_string(&ApplicationConfig::default()).unwrap();
|
||||
std::fs::write(config_file, config)?;
|
||||
Ok(ApplicationConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_addr(&self) -> SocketAddr {
|
||||
self.server_addr
|
||||
}
|
||||
|
||||
pub fn client_addr(&self) -> SocketAddr {
|
||||
self.client_addr
|
||||
}
|
||||
|
||||
pub fn max_connections(&self) -> Option<usize> {
|
||||
self.max_connections
|
||||
}
|
||||
|
||||
pub fn filter(&self) -> &Option<String> {
|
||||
&self.filter
|
||||
}
|
||||
|
||||
pub fn selected_packets(&self) -> &Option<BTreeMap<MetaPacket, bool>> {
|
||||
&self.selected_packets
|
||||
}
|
||||
|
||||
pub fn set_server_addr(&mut self, addr: SocketAddr) {
|
||||
self.must_save = true;
|
||||
self.server_addr = addr;
|
||||
}
|
||||
|
||||
pub fn set_client_addr(&mut self, addr: SocketAddr) {
|
||||
self.must_save = true;
|
||||
self.client_addr = addr;
|
||||
}
|
||||
|
||||
pub fn set_max_connections(&mut self, max: Option<usize>) {
|
||||
self.must_save = true;
|
||||
self.max_connections = max;
|
||||
}
|
||||
|
||||
pub fn set_filter(&mut self, filter: Option<String>) {
|
||||
self.must_save = true;
|
||||
self.filter = filter;
|
||||
}
|
||||
|
||||
pub fn set_selected_packets(&mut self, packets: BTreeMap<MetaPacket, bool>) {
|
||||
self.must_save = true;
|
||||
self.selected_packets = Some(packets);
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !self.must_save {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.must_save = false;
|
||||
|
||||
let config_dir = match get_or_create_project_dirs() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
eprintln!("Could not find config directory: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let config_file = config_dir.join("config.toml");
|
||||
|
||||
let config = toml::to_string(&self).unwrap();
|
||||
std::fs::write(config_file, config).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_create_project_dirs() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
if let Some(proj_dirs) = ProjectDirs::from("com", "valence", "inspector") {
|
||||
// check if the directory exists, if not create it
|
||||
if !proj_dirs.config_dir().exists() {
|
||||
std::fs::create_dir_all(proj_dirs.config_dir())?;
|
||||
}
|
||||
|
||||
Ok(proj_dirs.config_dir().to_owned())
|
||||
} else {
|
||||
Err("Could not find project directories".into())
|
||||
}
|
||||
}
|
386
crates/packet_inspector/src/context.rs
Normal file
386
crates/packet_inspector/src/context.rs
Normal file
|
@ -0,0 +1,386 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use owo_colors::{OwoColorize, Style};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use valence_protocol::codec::PacketDecoder;
|
||||
use valence_protocol::packet::c2s::handshake::HandshakeC2s;
|
||||
use valence_protocol::packet::c2s::login::{LoginHelloC2s, LoginKeyC2s};
|
||||
use valence_protocol::packet::c2s::status::{QueryPingC2s, QueryRequestC2s};
|
||||
use valence_protocol::packet::s2c::login::LoginSuccessS2c;
|
||||
use valence_protocol::packet::s2c::status::{QueryPongS2c, QueryResponseS2c};
|
||||
use valence_protocol::packet::{C2sPlayPacket, S2cLoginPacket, S2cPlayPacket};
|
||||
use valence_protocol::raw::RawPacket;
|
||||
|
||||
use crate::packet_widget::{systemtime_strftime, PacketDirection};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Stage {
|
||||
HandshakeC2s,
|
||||
QueryRequestC2s,
|
||||
QueryResponseS2c,
|
||||
QueryPingC2s,
|
||||
QueryPongS2c,
|
||||
LoginHelloC2s,
|
||||
S2cLoginPacket,
|
||||
LoginKeyC2s,
|
||||
LoginSuccessS2c,
|
||||
C2sPlayPacket,
|
||||
S2cPlayPacket,
|
||||
}
|
||||
|
||||
impl From<Stage> for usize {
|
||||
fn from(stage: Stage) -> Self {
|
||||
match stage {
|
||||
Stage::HandshakeC2s => 0,
|
||||
Stage::QueryRequestC2s => 1,
|
||||
Stage::QueryResponseS2c => 2,
|
||||
Stage::QueryPingC2s => 3,
|
||||
Stage::QueryPongS2c => 4,
|
||||
Stage::LoginHelloC2s => 5,
|
||||
Stage::S2cLoginPacket => 6,
|
||||
Stage::LoginKeyC2s => 7,
|
||||
Stage::LoginSuccessS2c => 8,
|
||||
Stage::C2sPlayPacket => 9,
|
||||
Stage::S2cPlayPacket => 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<usize> for Stage {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: usize) -> anyhow::Result<Self> {
|
||||
match value {
|
||||
0 => Ok(Stage::HandshakeC2s),
|
||||
1 => Ok(Stage::QueryRequestC2s),
|
||||
2 => Ok(Stage::QueryResponseS2c),
|
||||
3 => Ok(Stage::QueryPingC2s),
|
||||
4 => Ok(Stage::QueryPongS2c),
|
||||
5 => Ok(Stage::LoginHelloC2s),
|
||||
6 => Ok(Stage::S2cLoginPacket),
|
||||
7 => Ok(Stage::LoginKeyC2s),
|
||||
8 => Ok(Stage::LoginSuccessS2c),
|
||||
9 => Ok(Stage::C2sPlayPacket),
|
||||
10 => Ok(Stage::S2cPlayPacket),
|
||||
_ => Err(anyhow::anyhow!("Invalid stage")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Packet {
|
||||
pub(crate) id: usize,
|
||||
pub(crate) direction: PacketDirection,
|
||||
pub(crate) selected: bool,
|
||||
pub(crate) use_compression: bool,
|
||||
pub(crate) packet_data: Vec<u8>,
|
||||
pub(crate) stage: Stage,
|
||||
pub(crate) packet_type: i32,
|
||||
pub(crate) packet_name: String,
|
||||
pub(crate) created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
pub(crate) fn selected(&mut self, value: bool) {
|
||||
self.selected = value;
|
||||
}
|
||||
|
||||
pub fn get_raw_packet(&self) -> Vec<u8> {
|
||||
let mut dec = PacketDecoder::new();
|
||||
dec.set_compression(self.use_compression);
|
||||
dec.queue_slice(&self.packet_data);
|
||||
|
||||
let pkt = match dec.try_next_packet::<RawPacket>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return vec![],
|
||||
Err(e) => {
|
||||
eprintln!("Error decoding packet: {e}");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
pkt.0.to_vec()
|
||||
}
|
||||
|
||||
pub fn get_packet_string(&self, formatted: bool) -> String {
|
||||
let mut dec = PacketDecoder::new();
|
||||
dec.set_compression(self.use_compression);
|
||||
dec.queue_slice(&self.packet_data);
|
||||
|
||||
match self.stage {
|
||||
Stage::HandshakeC2s => {
|
||||
let pkt = match dec.try_next_packet::<HandshakeC2s>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "HandshakeC2s".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::QueryRequestC2s => {
|
||||
let pkt = match dec.try_next_packet::<QueryRequestC2s>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "QueryRequestC2s".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::QueryResponseS2c => {
|
||||
let pkt = match dec.try_next_packet::<QueryResponseS2c>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "QueryResponseS2c".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::QueryPingC2s => {
|
||||
let pkt = match dec.try_next_packet::<QueryPingC2s>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "QueryPingC2s".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::QueryPongS2c => {
|
||||
let pkt = match dec.try_next_packet::<QueryPongS2c>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "QueryPongS2c".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::LoginHelloC2s => {
|
||||
let pkt = match dec.try_next_packet::<LoginHelloC2s>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "LoginHelloC2s".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::S2cLoginPacket => {
|
||||
let pkt = match dec.try_next_packet::<S2cLoginPacket>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "S2cLoginPacket".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::LoginKeyC2s => {
|
||||
let pkt = match dec.try_next_packet::<LoginKeyC2s>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "LoginKeyC2s".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::LoginSuccessS2c => {
|
||||
let pkt = match dec.try_next_packet::<LoginSuccessS2c>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "LoginSuccessS2c".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::C2sPlayPacket => {
|
||||
let pkt = match dec.try_next_packet::<C2sPlayPacket>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "C2sPlayPacket".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
Stage::S2cPlayPacket => {
|
||||
let pkt = match dec.try_next_packet::<S2cPlayPacket>() {
|
||||
Ok(Some(pkt)) => pkt,
|
||||
Ok(None) => return "S2cPlayPacket".to_string(),
|
||||
Err(err) => return format!("{:?}", err),
|
||||
};
|
||||
|
||||
if formatted {
|
||||
format!("{pkt:#?}")
|
||||
} else {
|
||||
format!("{pkt:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Logger {
|
||||
pub include_filter: Option<Regex>,
|
||||
pub exclude_filter: Option<Regex>,
|
||||
}
|
||||
|
||||
pub enum ContextMode {
|
||||
Gui(egui::Context),
|
||||
Cli(Logger),
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
pub mode: ContextMode,
|
||||
pub last_packet: AtomicUsize,
|
||||
pub selected_packet: RwLock<Option<usize>>,
|
||||
pub(crate) packets: RwLock<Vec<Packet>>,
|
||||
pub(crate) packet_count: RwLock<usize>,
|
||||
pub(crate) has_encryption_enabled_error: AtomicBool,
|
||||
pub filter: RwLock<String>,
|
||||
c2s_style: Style,
|
||||
s2c_style: Style,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(mode: ContextMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
last_packet: AtomicUsize::new(0),
|
||||
selected_packet: RwLock::new(None),
|
||||
packets: RwLock::new(Vec::new()),
|
||||
filter: RwLock::new("".into()),
|
||||
packet_count: RwLock::new(0),
|
||||
|
||||
has_encryption_enabled_error: AtomicBool::new(false),
|
||||
|
||||
c2s_style: Style::new().green(),
|
||||
s2c_style: Style::new().purple(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.last_packet.store(0, Ordering::Relaxed);
|
||||
*self.selected_packet.write().expect("Poisoned RwLock") = None;
|
||||
self.packets.write().expect("Poisoned RwLock").clear();
|
||||
if let ContextMode::Gui(ctx) = &self.mode {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&self, mut packet: Packet) {
|
||||
match &self.mode {
|
||||
ContextMode::Gui(ctx) => {
|
||||
packet.id = self.last_packet.fetch_add(1, Ordering::Relaxed);
|
||||
self.packets.write().expect("Poisoned RwLock").push(packet);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
ContextMode::Cli(logger) => {
|
||||
if let Some(include_filter) = &logger.include_filter {
|
||||
if !include_filter.is_match(&packet.packet_name) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(exclude_filter) = &logger.exclude_filter {
|
||||
if exclude_filter.is_match(&packet.packet_name) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let arrow = match &packet.direction {
|
||||
PacketDirection::ClientToServer => "↑",
|
||||
PacketDirection::ServerToClient => "↓",
|
||||
};
|
||||
|
||||
if atty::is(atty::Stream::Stdout) {
|
||||
let style = match &packet.direction {
|
||||
PacketDirection::ClientToServer => self.c2s_style,
|
||||
PacketDirection::ServerToClient => self.s2c_style,
|
||||
};
|
||||
|
||||
println!(
|
||||
"[{}] ({}) {}",
|
||||
systemtime_strftime(packet.created_at),
|
||||
arrow.style(style),
|
||||
packet.get_packet_string(false).style(style)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"[{}] ({}) {}",
|
||||
systemtime_strftime(packet.created_at),
|
||||
arrow,
|
||||
packet.get_packet_string(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_selected_packet(&self, idx: usize) {
|
||||
*self.selected_packet.write().expect("Poisoned RwLock") = Some(idx);
|
||||
}
|
||||
|
||||
pub fn set_filter(&self, filter: String) {
|
||||
*self.filter.write().expect("Posisoned RwLock") = filter;
|
||||
*self.selected_packet.write().expect("Poisoned RwLock") = None;
|
||||
}
|
||||
|
||||
pub fn save(&self, path: PathBuf) -> Result<(), std::io::Error> {
|
||||
let packets = self
|
||||
.packets
|
||||
.read()
|
||||
.expect("Poisoned RwLock")
|
||||
.iter()
|
||||
.map(|packet| {
|
||||
format!(
|
||||
"[{}] {}",
|
||||
systemtime_strftime(packet.created_at),
|
||||
packet.get_packet_string(false)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
|
||||
std::fs::write(path, packets)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
45
crates/packet_inspector/src/hex_viewer.rs
Normal file
45
crates/packet_inspector/src/hex_viewer.rs
Normal file
|
@ -0,0 +1,45 @@
|
|||
use std::io::Read;
|
||||
|
||||
use egui::Color32;
|
||||
|
||||
pub fn hex_view_ui(ui: &mut egui::Ui, mut file: &[u8]) {
|
||||
let mut buf = [0u8; 16];
|
||||
let mut count = 0;
|
||||
|
||||
egui::Grid::new("hex_grid")
|
||||
.spacing([4.0, 1.5])
|
||||
.striped(true)
|
||||
.min_col_width(0.0)
|
||||
.show(ui, |ui| {
|
||||
ui.label(" ");
|
||||
for i in 0..16 {
|
||||
ui.label(format!("{:02X}", i));
|
||||
}
|
||||
ui.end_row();
|
||||
loop {
|
||||
let bytes_read = file.read(&mut buf).unwrap();
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
ui.label(format!("{:08X}", count));
|
||||
for b in buf.iter().take(bytes_read) {
|
||||
ui.colored_label(Color32::from_rgb(255, 255, 255), format!("{:02X}", b));
|
||||
}
|
||||
for _ in 0..16 - bytes_read {
|
||||
ui.label(" ");
|
||||
}
|
||||
ui.label(" ");
|
||||
for b in buf.iter().take(bytes_read) {
|
||||
if *b >= 0x20 && *b <= 0x7e {
|
||||
ui.label(format!("{}", *b as char));
|
||||
} else {
|
||||
ui.label(".");
|
||||
}
|
||||
}
|
||||
|
||||
ui.end_row();
|
||||
count += bytes_read;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,14 +1,25 @@
|
|||
use std::error::Error;
|
||||
use std::fmt::Write;
|
||||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
mod config;
|
||||
mod context;
|
||||
mod hex_viewer;
|
||||
mod packet_widget;
|
||||
mod state;
|
||||
mod syntax_highlighting;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::bail;
|
||||
use clap::Parser;
|
||||
use owo_colors::OwoColorize;
|
||||
use config::ApplicationConfig;
|
||||
use context::{Context, Packet};
|
||||
use egui::{Align2, RichText};
|
||||
use hex_viewer::hex_view_ui;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use syntax_highlighting::code_view_ui;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
@ -23,107 +34,79 @@ use valence_protocol::packet::c2s::status::{QueryPingC2s, QueryRequestC2s};
|
|||
use valence_protocol::packet::s2c::login::LoginSuccessS2c;
|
||||
use valence_protocol::packet::s2c::status::{QueryPongS2c, QueryResponseS2c};
|
||||
use valence_protocol::packet::{C2sPlayPacket, S2cLoginPacket, S2cPlayPacket};
|
||||
use valence_protocol::Packet;
|
||||
|
||||
use crate::context::{ContextMode, Stage};
|
||||
use crate::packet_widget::PacketDirection;
|
||||
use crate::state::State;
|
||||
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
#[clap(author, version, about)]
|
||||
struct Cli {
|
||||
/// The socket address to listen for connections on. This is the address
|
||||
/// clients should connect to.
|
||||
client_addr: SocketAddr,
|
||||
#[arg(required_if_eq("nogui", "true"))]
|
||||
client_addr: Option<SocketAddr>,
|
||||
|
||||
/// The socket address the proxy will connect to. This is the address of the
|
||||
/// server.
|
||||
server_addr: SocketAddr,
|
||||
/// An optional regular expression to use on packet names. Packet names
|
||||
/// matching the regex are printed while those that don't are ignored.
|
||||
///
|
||||
/// If no regex is provided, all packets are considered matching.
|
||||
#[clap(short, long)]
|
||||
include_regex: Option<Regex>,
|
||||
/// An optional regular expression to use on packet names. Packet names
|
||||
/// matching the regex are ignored while those are don't are printed.
|
||||
///
|
||||
/// If no regex is provided, all packets are not considered matching.
|
||||
#[clap(short, long)]
|
||||
exclude_regex: Option<Regex>,
|
||||
#[arg(required_if_eq("nogui", "true"))]
|
||||
server_addr: Option<SocketAddr>,
|
||||
|
||||
/// The maximum number of connections allowed to the proxy. By default,
|
||||
/// there is no limit.
|
||||
#[clap(short, long)]
|
||||
max_connections: Option<usize>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
cli: Arc<Cli>,
|
||||
enc: PacketEncoder,
|
||||
dec: PacketDecoder,
|
||||
read: OwnedReadHalf,
|
||||
write: OwnedWriteHalf,
|
||||
buf: String,
|
||||
style: owo_colors::Style,
|
||||
}
|
||||
/// Disable the GUI. Logging to stdout.
|
||||
#[clap(long)]
|
||||
nogui: bool,
|
||||
|
||||
impl State {
|
||||
pub async fn rw_packet<'a, P>(&'a mut self) -> anyhow::Result<P>
|
||||
where
|
||||
P: Packet<'a>,
|
||||
{
|
||||
while !self.dec.has_next_packet()? {
|
||||
self.dec.reserve(4096);
|
||||
let mut buf = self.dec.take_capacity();
|
||||
/// Only show packets that match the filter.
|
||||
#[clap(short, long)]
|
||||
include_filter: Option<Regex>,
|
||||
|
||||
if self.read.read_buf(&mut buf).await? == 0 {
|
||||
return Err(io::Error::from(ErrorKind::UnexpectedEof).into());
|
||||
}
|
||||
|
||||
self.dec.queue_bytes(buf);
|
||||
}
|
||||
|
||||
let pkt: P = self.dec.try_next_packet()?.unwrap();
|
||||
|
||||
self.enc.append_packet(&pkt)?;
|
||||
|
||||
let bytes = self.enc.take();
|
||||
self.write.write_all(&bytes).await?;
|
||||
|
||||
self.buf.clear();
|
||||
write!(&mut self.buf, "{pkt:?}")?;
|
||||
|
||||
let packet_name = self
|
||||
.buf
|
||||
.split_once(|ch: char| !ch.is_ascii_alphabetic())
|
||||
.map(|(fst, _)| fst)
|
||||
.unwrap_or(&self.buf);
|
||||
|
||||
if let Some(r) = &self.cli.include_regex {
|
||||
if !r.is_match(packet_name) {
|
||||
return Ok(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(r) = &self.cli.exclude_regex {
|
||||
if r.is_match(packet_name) {
|
||||
return Ok(pkt);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", self.buf.style(self.style));
|
||||
|
||||
Ok(pkt)
|
||||
}
|
||||
/// Hide packets that match the filter. Note: Only in effect if nogui is
|
||||
/// set.
|
||||
#[clap(short, long)]
|
||||
exclude_filter: Option<Regex>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(LevelFilter::DEBUG)
|
||||
.init();
|
||||
|
||||
let cli = Arc::new(Cli::parse());
|
||||
|
||||
match cli.nogui {
|
||||
true => start_cli(cli).await?,
|
||||
false => start_gui(cli)?,
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_cli(cli: Arc<Cli>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let context = Arc::new(Context::new(ContextMode::Cli(context::Logger {
|
||||
include_filter: cli.include_filter.clone(),
|
||||
exclude_filter: cli.exclude_filter.clone(),
|
||||
})));
|
||||
|
||||
let sema = Arc::new(Semaphore::new(cli.max_connections.unwrap_or(100_000)));
|
||||
|
||||
eprintln!("Waiting for connections on {}", cli.client_addr);
|
||||
let listen = TcpListener::bind(cli.client_addr).await?;
|
||||
let client_addr = match cli.client_addr {
|
||||
Some(addr) => addr,
|
||||
None => return Err("Missing Client Address".into()),
|
||||
};
|
||||
|
||||
let server_addr = match cli.server_addr {
|
||||
Some(addr) => addr,
|
||||
None => return Err("Missing Server Address".into()),
|
||||
};
|
||||
|
||||
eprintln!("Waiting for connections on {}", client_addr);
|
||||
let listen = TcpListener::bind(client_addr).await?;
|
||||
|
||||
while let Ok(permit) = sema.clone().acquire_owned().await {
|
||||
let (client, remote_client_addr) = listen.accept().await?;
|
||||
|
@ -133,9 +116,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||
eprintln!("Failed to set TCP_NODELAY: {e}");
|
||||
}
|
||||
|
||||
let cli = cli.clone();
|
||||
let context = context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(client, cli).await {
|
||||
if let Err(e) = handle_connection(client, server_addr, context).await {
|
||||
eprintln!("Connection to {remote_client_addr} ended with: {e:#}");
|
||||
} else {
|
||||
eprintln!("Connection to {remote_client_addr} ended.");
|
||||
|
@ -147,10 +130,50 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(client: TcpStream, cli: Arc<Cli>) -> anyhow::Result<()> {
|
||||
eprintln!("Connecting to {}", cli.server_addr);
|
||||
fn start_gui(cli: Arc<Cli>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let native_options = eframe::NativeOptions {
|
||||
initial_window_size: Some(egui::Vec2::new(800.0, 600.0)),
|
||||
decorated: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let server = TcpStream::connect(cli.server_addr).await?;
|
||||
let server_addr = cli.server_addr;
|
||||
let client_addr = cli.client_addr;
|
||||
let max_connections = cli.max_connections.unwrap_or(100_000);
|
||||
|
||||
let filter = cli
|
||||
.include_filter
|
||||
.clone()
|
||||
.map(|f| f.to_string())
|
||||
.unwrap_or("".to_string());
|
||||
|
||||
eframe::run_native(
|
||||
"Valence Packet Inspector",
|
||||
native_options,
|
||||
Box::new(move |cc| {
|
||||
let gui_app = GuiApp::new(cc, filter);
|
||||
|
||||
if let Some(server_addr) = server_addr {
|
||||
if let Some(client_addr) = client_addr {
|
||||
gui_app.start_listening(client_addr, server_addr, max_connections);
|
||||
}
|
||||
}
|
||||
|
||||
Box::new(gui_app)
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
client: TcpStream,
|
||||
server_addr: SocketAddr,
|
||||
context: Arc<Context>,
|
||||
) -> anyhow::Result<()> {
|
||||
eprintln!("Connecting to {}", server_addr);
|
||||
|
||||
let server = TcpStream::connect(server_addr).await?;
|
||||
|
||||
if let Err(e) = server.set_nodelay(true) {
|
||||
eprintln!("Failed to set TCP_NODELAY: {e}");
|
||||
|
@ -160,48 +183,55 @@ async fn handle_connection(client: TcpStream, cli: Arc<Cli>) -> anyhow::Result<(
|
|||
let (server_read, server_write) = server.into_split();
|
||||
|
||||
let mut s2c = State {
|
||||
cli: cli.clone(),
|
||||
enc: PacketEncoder::new(),
|
||||
dec: PacketDecoder::new(),
|
||||
read: server_read,
|
||||
write: client_write,
|
||||
buf: String::new(),
|
||||
style: owo_colors::Style::new().purple(),
|
||||
direction: PacketDirection::ServerToClient,
|
||||
context: context.clone(),
|
||||
};
|
||||
|
||||
let mut c2s = State {
|
||||
cli,
|
||||
enc: PacketEncoder::new(),
|
||||
dec: PacketDecoder::new(),
|
||||
read: client_read,
|
||||
write: server_write,
|
||||
buf: String::new(),
|
||||
style: owo_colors::Style::new().green(),
|
||||
direction: PacketDirection::ClientToServer,
|
||||
context: context.clone(),
|
||||
};
|
||||
|
||||
let handshake: HandshakeC2s = c2s.rw_packet().await?;
|
||||
let handshake: HandshakeC2s = c2s.rw_packet(Stage::HandshakeC2s).await?;
|
||||
|
||||
match handshake.next_state {
|
||||
NextState::Status => {
|
||||
c2s.rw_packet::<QueryRequestC2s>().await?;
|
||||
s2c.rw_packet::<QueryResponseS2c>().await?;
|
||||
c2s.rw_packet::<QueryPingC2s>().await?;
|
||||
s2c.rw_packet::<QueryPongS2c>().await?;
|
||||
c2s.rw_packet::<QueryRequestC2s>(Stage::QueryRequestC2s)
|
||||
.await?;
|
||||
s2c.rw_packet::<QueryResponseS2c>(Stage::QueryResponseS2c)
|
||||
.await?;
|
||||
c2s.rw_packet::<QueryPingC2s>(Stage::QueryPingC2s).await?;
|
||||
s2c.rw_packet::<QueryPongS2c>(Stage::QueryPongS2c).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
NextState::Login => {
|
||||
c2s.rw_packet::<LoginHelloC2s>().await?;
|
||||
c2s.rw_packet::<LoginHelloC2s>(Stage::LoginHelloC2s).await?;
|
||||
|
||||
match s2c.rw_packet::<S2cLoginPacket>().await? {
|
||||
match s2c
|
||||
.rw_packet::<S2cLoginPacket>(Stage::S2cLoginPacket)
|
||||
.await?
|
||||
{
|
||||
S2cLoginPacket::LoginHelloS2c(_) => {
|
||||
c2s.rw_packet::<LoginKeyC2s>().await?;
|
||||
c2s.rw_packet::<LoginKeyC2s>(Stage::LoginKeyC2s).await?;
|
||||
|
||||
eprintln!(
|
||||
"Encryption was enabled! Packet contents are inaccessible to the proxy. \
|
||||
Disable online_mode to fix this."
|
||||
"Encryption is enabled! Packet contents are inaccessible to the proxy. \
|
||||
Disable online mode to fix this."
|
||||
);
|
||||
|
||||
context
|
||||
.has_encryption_enabled_error
|
||||
.store(true, Ordering::Relaxed);
|
||||
|
||||
return tokio::select! {
|
||||
c2s_res = passthrough(c2s.read, c2s.write) => c2s_res,
|
||||
s2c_res = passthrough(s2c.read, s2c.write) => s2c_res,
|
||||
|
@ -215,30 +245,31 @@ async fn handle_connection(client: TcpStream, cli: Arc<Cli>) -> anyhow::Result<(
|
|||
c2s.enc.set_compression(Some(threshold));
|
||||
c2s.dec.set_compression(true);
|
||||
|
||||
s2c.rw_packet::<LoginSuccessS2c>().await?;
|
||||
s2c.rw_packet::<LoginSuccessS2c>(Stage::LoginSuccessS2c)
|
||||
.await?;
|
||||
}
|
||||
S2cLoginPacket::LoginSuccessS2c(_) => {}
|
||||
S2cLoginPacket::LoginDisconnectS2c(_) => return Ok(()),
|
||||
S2cLoginPacket::LoginQueryRequestS2c(_) => {
|
||||
bail!("got login query request. Don't know how to proceed.")
|
||||
bail!("got login plugin request. Don't know how to proceed.")
|
||||
}
|
||||
}
|
||||
|
||||
let c2s_fut: JoinHandle<anyhow::Result<()>> = tokio::spawn(async move {
|
||||
loop {
|
||||
c2s.rw_packet::<C2sPlayPacket>().await?;
|
||||
c2s.rw_packet::<C2sPlayPacket>(Stage::C2sPlayPacket).await?;
|
||||
}
|
||||
});
|
||||
|
||||
let s2c_fut = async move {
|
||||
let s2c_fut: JoinHandle<anyhow::Result<()>> = tokio::spawn(async move {
|
||||
loop {
|
||||
s2c.rw_packet::<S2cPlayPacket>().await?;
|
||||
s2c.rw_packet::<S2cPlayPacket>(Stage::S2cPlayPacket).await?;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
c2s = c2s_fut => Ok(c2s??),
|
||||
s2c = s2c_fut => s2c,
|
||||
s2c = s2c_fut => Ok(s2c??),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -257,3 +288,607 @@ async fn passthrough(mut read: OwnedReadHalf, mut write: OwnedWriteHalf) -> anyh
|
|||
write.write_all(bytes).await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MetaPacket {
|
||||
id: i32,
|
||||
stage: Stage,
|
||||
direction: PacketDirection,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// manually implement Serialize and Deserialize that use the ToString and
|
||||
// FromStr implementaions for keys
|
||||
impl Serialize for MetaPacket {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for MetaPacket {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
MetaPacket::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(Stage, i32, PacketDirection, String)> for MetaPacket {
|
||||
fn from((stage, id, direction, name): (Stage, i32, PacketDirection, String)) -> Self {
|
||||
Self {
|
||||
stage,
|
||||
id,
|
||||
direction,
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// to string and from string to be used in toml
|
||||
impl ToString for MetaPacket {
|
||||
fn to_string(&self) -> String {
|
||||
let stage: usize = self.stage.clone().into();
|
||||
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
stage,
|
||||
self.id,
|
||||
match self.direction {
|
||||
PacketDirection::ClientToServer => 0,
|
||||
PacketDirection::ServerToClient => 1,
|
||||
},
|
||||
self.name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MetaPacket {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut split = s.split(':');
|
||||
let stage = match split.next().unwrap().parse::<usize>() {
|
||||
Ok(stage) => Stage::try_from(stage)?,
|
||||
Err(_) => bail!("invalid stage"),
|
||||
};
|
||||
let id = split.next().unwrap().parse::<i32>()?;
|
||||
let direction = match split.next().unwrap().parse::<i32>()? {
|
||||
0 => PacketDirection::ClientToServer,
|
||||
1 => PacketDirection::ServerToClient,
|
||||
_ => bail!("invalid direction"),
|
||||
};
|
||||
let name = split.next().unwrap().to_string();
|
||||
|
||||
Ok(Self {
|
||||
stage,
|
||||
id,
|
||||
direction,
|
||||
name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for MetaPacket {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct OrdMetaPacket {
|
||||
stage: Stage,
|
||||
id: i32,
|
||||
direction: PacketDirection,
|
||||
}
|
||||
|
||||
let left = OrdMetaPacket {
|
||||
stage: self.stage.clone(),
|
||||
id: self.id,
|
||||
direction: self.direction.clone(),
|
||||
};
|
||||
|
||||
let right = OrdMetaPacket {
|
||||
stage: other.stage.clone(),
|
||||
id: other.id,
|
||||
direction: other.direction.clone(),
|
||||
};
|
||||
|
||||
left.cmp(&right)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for MetaPacket {
|
||||
fn partial_cmp(&self, other: &Self) -> std::option::Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for MetaPacket {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.stage == other.stage && self.id == other.id && self.direction == other.direction
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for MetaPacket {}
|
||||
|
||||
struct GuiApp {
|
||||
config: ApplicationConfig,
|
||||
temp_server_addr: String,
|
||||
temp_client_addr: String,
|
||||
temp_max_connections: String,
|
||||
|
||||
server_addr_error: bool,
|
||||
client_addr_error: bool,
|
||||
max_connections_error: bool,
|
||||
|
||||
context: Arc<Context>,
|
||||
filter: String,
|
||||
selected_packets: BTreeMap<MetaPacket, bool>,
|
||||
buffer: String,
|
||||
is_listening: RwLock<bool>,
|
||||
window_open: bool,
|
||||
encryption_error_dialog_open: bool,
|
||||
|
||||
config_load_error: Option<String>,
|
||||
config_load_error_window_open: bool,
|
||||
|
||||
raw_packet: Vec<u8>,
|
||||
view_hex: bool,
|
||||
}
|
||||
|
||||
impl GuiApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, filter: String) -> Self {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
|
||||
let context = Context::new(ContextMode::Gui(ctx));
|
||||
|
||||
let mut config_load_error: Option<String> = None;
|
||||
|
||||
let mut config = match ApplicationConfig::load() {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
config_load_error = Some(format!("Failed to load config:\n{}", e));
|
||||
ApplicationConfig::default()
|
||||
}
|
||||
};
|
||||
|
||||
let mut filter = filter;
|
||||
|
||||
if filter.is_empty() {
|
||||
if let Some(c_filter) = config.filter() {
|
||||
filter = c_filter.to_string();
|
||||
}
|
||||
} else {
|
||||
config.set_filter(Some(filter.clone()));
|
||||
}
|
||||
|
||||
{
|
||||
let mut f = context.filter.write().expect("Poisoned filter");
|
||||
*f = filter.clone();
|
||||
}
|
||||
|
||||
let context = Arc::new(context);
|
||||
|
||||
let temp_server_addr = config.server_addr().to_string();
|
||||
let temp_client_addr = config.client_addr().to_string();
|
||||
let temp_max_connections = match config.max_connections() {
|
||||
Some(max_connections) => max_connections.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let selected_packets = match config.selected_packets().clone() {
|
||||
Some(selected_packets) => selected_packets,
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
context,
|
||||
filter,
|
||||
selected_packets,
|
||||
buffer: String::new(),
|
||||
is_listening: RwLock::new(false),
|
||||
window_open: false,
|
||||
encryption_error_dialog_open: false,
|
||||
|
||||
temp_server_addr,
|
||||
temp_client_addr,
|
||||
temp_max_connections,
|
||||
|
||||
server_addr_error: false,
|
||||
client_addr_error: false,
|
||||
max_connections_error: false,
|
||||
|
||||
config_load_error,
|
||||
config_load_error_window_open: false,
|
||||
|
||||
raw_packet: Vec::new(),
|
||||
view_hex: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_listening(
|
||||
&self,
|
||||
client_addr: SocketAddr,
|
||||
server_addr: SocketAddr,
|
||||
max_connections: usize,
|
||||
) {
|
||||
let t_context = self.context.clone();
|
||||
tokio::spawn(async move {
|
||||
let sema = Arc::new(Semaphore::new(max_connections));
|
||||
|
||||
let listen = TcpListener::bind(client_addr).await?;
|
||||
eprintln!("Waiting for connections on {}", client_addr);
|
||||
|
||||
while let Ok(permit) = sema.clone().acquire_owned().await {
|
||||
let (client, remote_client_addr) = listen.accept().await?;
|
||||
eprintln!("Accepted connection to {remote_client_addr}");
|
||||
|
||||
if let Err(e) = client.set_nodelay(true) {
|
||||
eprintln!("Failed to set TCP_NODELAY: {e}");
|
||||
}
|
||||
|
||||
let t2_context = t_context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(client, server_addr, t2_context).await {
|
||||
eprintln!("Connection to {remote_client_addr} ended with: {e:#}");
|
||||
} else {
|
||||
eprintln!("Connection to {remote_client_addr} ended.");
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
*self.is_listening.write().expect("Poisoned is_listening") = true;
|
||||
}
|
||||
|
||||
fn nested_menus(&mut self, ui: &mut egui::Ui) {
|
||||
let mut changed = false;
|
||||
self.selected_packets
|
||||
.iter_mut()
|
||||
.for_each(|(m_packet, selected)| {
|
||||
// todo: format, add arrows, etc
|
||||
if ui.checkbox(selected, m_packet.name.clone()).changed() {
|
||||
changed = true;
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
});
|
||||
|
||||
if changed {
|
||||
self.config
|
||||
.set_selected_packets(self.selected_packets.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for GuiApp {
|
||||
fn on_close_event(&mut self) -> bool {
|
||||
match self.config.save() {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to save config: {e}");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if !*self.is_listening.read().expect("Poisoned is_listening") {
|
||||
self.window_open = true;
|
||||
}
|
||||
|
||||
if self
|
||||
.context
|
||||
.has_encryption_enabled_error
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
self.encryption_error_dialog_open = true;
|
||||
}
|
||||
|
||||
if self.encryption_error_dialog_open {
|
||||
egui::Window::new("Encryption Error")
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.open(&mut self.encryption_error_dialog_open)
|
||||
.movable(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
"Encryption is enabled! Packet contents are inaccessible to the proxy. \
|
||||
Disable online mode to fix this.",
|
||||
);
|
||||
});
|
||||
|
||||
// it was true, now it's false, the user acknowledged the error, set it to false
|
||||
if !self.encryption_error_dialog_open {
|
||||
self.context
|
||||
.has_encryption_enabled_error
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
if self.config_load_error.is_some() {
|
||||
self.config_load_error_window_open = true;
|
||||
}
|
||||
|
||||
if self.config_load_error_window_open {
|
||||
if let Some(err) = &self.config_load_error {
|
||||
egui::Window::new("Config Error")
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.open(&mut self.config_load_error_window_open)
|
||||
.movable(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(err);
|
||||
});
|
||||
}
|
||||
|
||||
if !self.config_load_error_window_open {
|
||||
self.config_load_error = None;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if self.window_open {
|
||||
egui::Window::new("Setup")
|
||||
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.movable(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("setup_grid")
|
||||
.num_columns(2)
|
||||
.spacing([40.0, 4.0])
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new("Server address:").color(
|
||||
match self.server_addr_error {
|
||||
true => egui::Color32::RED,
|
||||
false => egui::Color32::WHITE,
|
||||
},
|
||||
));
|
||||
if ui
|
||||
.text_edit_singleline(&mut self.temp_server_addr)
|
||||
.on_hover_text(
|
||||
"The socket address the proxy will connect to. This is the \
|
||||
address of the server.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.server_addr_error = false;
|
||||
};
|
||||
ui.end_row();
|
||||
ui.label(RichText::new("Client address:").color(
|
||||
match self.client_addr_error {
|
||||
true => egui::Color32::RED,
|
||||
false => egui::Color32::WHITE,
|
||||
},
|
||||
));
|
||||
ui.text_edit_singleline(&mut self.temp_client_addr)
|
||||
.on_hover_text(
|
||||
"The socket address to listen for connections on. This is the \
|
||||
address clients should connect to.",
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label(RichText::new("Max Connections:").color(
|
||||
match self.max_connections_error {
|
||||
true => egui::Color32::RED,
|
||||
false => egui::Color32::WHITE,
|
||||
},
|
||||
));
|
||||
ui.text_edit_singleline(&mut self.temp_max_connections)
|
||||
.on_hover_text(
|
||||
"The maximum number of connections allowed to the proxy. By \
|
||||
default, there is no limit.",
|
||||
);
|
||||
ui.end_row();
|
||||
if ui.button("Start Proxy").clicked() {
|
||||
self.window_open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if !self.window_open {
|
||||
let server_addr = self.temp_server_addr.parse::<SocketAddr>().map_err(|_| {
|
||||
self.server_addr_error = true;
|
||||
});
|
||||
|
||||
let client_addr = self.temp_client_addr.parse::<SocketAddr>().map_err(|_| {
|
||||
self.client_addr_error = true;
|
||||
});
|
||||
|
||||
let max_connections = if self.temp_max_connections.is_empty() {
|
||||
Ok(100_000)
|
||||
} else {
|
||||
self.temp_max_connections.parse::<usize>().map_err(|_| {
|
||||
self.max_connections_error = true;
|
||||
})
|
||||
};
|
||||
|
||||
if server_addr.is_err() || client_addr.is_err() || max_connections.is_err() {
|
||||
self.window_open = true;
|
||||
return;
|
||||
}
|
||||
|
||||
self.config.set_server_addr(server_addr.unwrap());
|
||||
self.config.set_client_addr(client_addr.unwrap());
|
||||
self.config
|
||||
.set_max_connections(if self.temp_max_connections.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.temp_max_connections.parse::<usize>().unwrap())
|
||||
});
|
||||
|
||||
self.start_listening(
|
||||
client_addr.unwrap(),
|
||||
server_addr.unwrap(),
|
||||
max_connections.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
egui::TopBottomPanel::top("header").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Filter:");
|
||||
if ui.text_edit_singleline(&mut self.filter).changed() {
|
||||
self.context.set_filter(self.filter.clone());
|
||||
|
||||
self.config.set_filter(match self.filter.is_empty() {
|
||||
true => None,
|
||||
false => Some(self.filter.clone()),
|
||||
});
|
||||
}
|
||||
ui.menu_button("Packets", |ui| {
|
||||
ui.set_max_width(250.0);
|
||||
ui.set_max_height(400.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([true, true])
|
||||
.show(ui, |ui| {
|
||||
self.nested_menus(ui);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
egui::SidePanel::left("side_panel")
|
||||
.min_width(150.0)
|
||||
.default_width(250.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Packets");
|
||||
|
||||
let count = self.context.packet_count.read().expect("Poisoned RwLock");
|
||||
let total = self.context.packets.read().expect("Poisoned RwLock").len();
|
||||
|
||||
let all_selected = self.selected_packets.values().all(|v| *v);
|
||||
|
||||
if self.filter.is_empty() && all_selected {
|
||||
ui.label(format!("({total})"));
|
||||
} else {
|
||||
ui.label(format!("({count}/{total})"));
|
||||
}
|
||||
|
||||
if ui.button("Clear").clicked() {
|
||||
self.context.clear();
|
||||
self.buffer = String::new();
|
||||
self.raw_packet = vec![];
|
||||
}
|
||||
|
||||
if ui.button("Export").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Text Document", &["txt"])
|
||||
.save_file()
|
||||
{
|
||||
match self.context.save(path) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to save: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.stick_to_bottom(true)
|
||||
.show(ui, |ui| {
|
||||
let mut f = self.context.packets.write().expect("Poisoned RwLock");
|
||||
|
||||
let f: Vec<&mut Packet> = f
|
||||
.iter_mut()
|
||||
.filter(|p| {
|
||||
let m_packet = MetaPacket {
|
||||
stage: p.stage.clone(),
|
||||
id: p.packet_type,
|
||||
direction: p.direction.clone(),
|
||||
name: p.packet_name.clone(),
|
||||
};
|
||||
|
||||
if !self.selected_packets.contains_key(&m_packet) {
|
||||
self.selected_packets.insert(m_packet.clone(), true);
|
||||
self.config
|
||||
.set_selected_packets(self.selected_packets.clone());
|
||||
} else {
|
||||
// if it does exist, check if the names are the same, if not
|
||||
// update the key
|
||||
let (existing, value) =
|
||||
self.selected_packets.get_key_value(&m_packet).unwrap();
|
||||
if existing.name != m_packet.name {
|
||||
let value = *value; // keep the old value
|
||||
self.selected_packets.remove(&m_packet);
|
||||
self.selected_packets.insert(m_packet.clone(), value);
|
||||
self.config
|
||||
.set_selected_packets(self.selected_packets.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(selected) = self.selected_packets.get(&m_packet) {
|
||||
if !*selected {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if self.filter.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Ok(re) = regex::Regex::new(&self.filter) {
|
||||
return re.is_match(&p.packet_name);
|
||||
}
|
||||
|
||||
false
|
||||
})
|
||||
.collect();
|
||||
|
||||
*self.context.packet_count.write().expect("Poisoned RwLock") = f.len();
|
||||
|
||||
for packet in f {
|
||||
{
|
||||
let selected = self
|
||||
.context
|
||||
.selected_packet
|
||||
.read()
|
||||
.expect("Poisoned RwLock");
|
||||
if let Some(idx) = *selected {
|
||||
if idx == packet.id {
|
||||
packet.selected(true);
|
||||
self.buffer = packet.get_packet_string(true);
|
||||
self.raw_packet = packet.get_raw_packet();
|
||||
} else {
|
||||
packet.selected(false);
|
||||
}
|
||||
} else {
|
||||
packet.selected(false);
|
||||
}
|
||||
}
|
||||
|
||||
if ui.add(packet.clone()).clicked() {
|
||||
self.context.set_selected_packet(packet.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut self.view_hex, "Hex View");
|
||||
});
|
||||
|
||||
egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if self.view_hex {
|
||||
hex_view_ui(ui, &self.raw_packet);
|
||||
} else {
|
||||
code_view_ui(ui, &self.buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
151
crates/packet_inspector/src/packet_widget.rs
Normal file
151
crates/packet_inspector/src/packet_widget.rs
Normal file
|
@ -0,0 +1,151 @@
|
|||
use eframe::epaint::{PathShape, RectShape};
|
||||
use egui::{
|
||||
Pos2, Rect, Response, Rgba, Rounding, Sense, Shape, Stroke, TextStyle, Ui, Vec2, Widget,
|
||||
WidgetText,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::context::Packet;
|
||||
|
||||
pub fn systemtime_strftime(odt: OffsetDateTime) -> String {
|
||||
let hour = odt.hour();
|
||||
let minute = odt.minute();
|
||||
let second = odt.second();
|
||||
let millis = odt.millisecond();
|
||||
|
||||
format!("{hour:0>2}:{minute:0>2}:{second:0>2}.{millis:0>4}")
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize)]
|
||||
pub enum PacketDirection {
|
||||
ClientToServer,
|
||||
ServerToClient,
|
||||
}
|
||||
|
||||
impl PacketDirection {
|
||||
fn get_shape(&self, outer_rect: &Rect) -> PathShape {
|
||||
let rect = Rect::from_min_size(
|
||||
Pos2 {
|
||||
x: outer_rect.left() + 6.0,
|
||||
y: outer_rect.top() + 8.0,
|
||||
},
|
||||
Vec2 { x: 8.0, y: 8.0 },
|
||||
);
|
||||
|
||||
let color = match self {
|
||||
PacketDirection::ServerToClient => Rgba::from_rgb(255.0, 0.0, 0.0),
|
||||
PacketDirection::ClientToServer => Rgba::from_rgb(0.0, 255.0, 0.0),
|
||||
};
|
||||
|
||||
let points = match self {
|
||||
PacketDirection::ServerToClient => vec![
|
||||
Pos2 {
|
||||
x: rect.left() + (rect.width() / 2.0),
|
||||
y: rect.top() + rect.height(),
|
||||
},
|
||||
Pos2 {
|
||||
x: rect.left() + 0.0,
|
||||
y: rect.top(),
|
||||
},
|
||||
Pos2 {
|
||||
x: rect.left() + rect.width(),
|
||||
y: rect.top(),
|
||||
},
|
||||
],
|
||||
PacketDirection::ClientToServer => vec![
|
||||
Pos2 {
|
||||
x: rect.left() + (rect.width() / 2.0),
|
||||
y: rect.top() + 0.0,
|
||||
},
|
||||
Pos2 {
|
||||
x: rect.left() + 0.0,
|
||||
y: rect.top() + rect.height(),
|
||||
},
|
||||
Pos2 {
|
||||
x: rect.left() + rect.width(),
|
||||
y: rect.top() + rect.height(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut shape = PathShape::closed_line(points, Stroke::new(2.0, color));
|
||||
shape.fill = color.into();
|
||||
|
||||
shape
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Packet {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let (mut rect, response) = ui.allocate_at_least(
|
||||
Vec2 {
|
||||
x: ui.available_width(),
|
||||
y: 24.0,
|
||||
},
|
||||
Sense::click(),
|
||||
);
|
||||
|
||||
let fill = match self.selected {
|
||||
true => Rgba::from_rgba_premultiplied(0.0, 0.0, 0.0, 0.4),
|
||||
false => Rgba::from_rgba_premultiplied(0.0, 0.0, 0.0, 0.0),
|
||||
};
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
ui.painter().add(Shape::Rect(RectShape {
|
||||
rect,
|
||||
rounding: Rounding::none(),
|
||||
fill: fill.into(),
|
||||
stroke: Stroke::new(1.0, Rgba::BLACK),
|
||||
}));
|
||||
|
||||
let shape = self.direction.get_shape(&rect);
|
||||
ui.painter().add(Shape::Path(shape));
|
||||
|
||||
let identifier: WidgetText = format!("0x{:0>2X?}", self.packet_type).into();
|
||||
|
||||
let identifier =
|
||||
identifier.into_galley(ui, Some(false), rect.width() - 21.0, TextStyle::Button);
|
||||
|
||||
let label: WidgetText = self.packet_name.into();
|
||||
let label = label.into_galley(ui, Some(false), rect.width() - 60.0, TextStyle::Button);
|
||||
|
||||
let timestamp: WidgetText = systemtime_strftime(self.created_at).into();
|
||||
let timestamp =
|
||||
timestamp.into_galley(ui, Some(false), rect.width() - 60.0, TextStyle::Button);
|
||||
|
||||
identifier.paint_with_fallback_color(
|
||||
ui.painter(),
|
||||
Pos2 {
|
||||
x: rect.left() + 21.0,
|
||||
y: rect.top() + 6.0,
|
||||
},
|
||||
ui.visuals().weak_text_color(),
|
||||
);
|
||||
|
||||
rect.set_width(rect.width() - 5.0);
|
||||
|
||||
let label_width = label.size().x + 50.0;
|
||||
|
||||
label.paint_with_fallback_color(
|
||||
&ui.painter().with_clip_rect(rect),
|
||||
Pos2 {
|
||||
x: rect.left() + 55.0,
|
||||
y: rect.top() + 6.0,
|
||||
},
|
||||
ui.visuals().strong_text_color(),
|
||||
);
|
||||
|
||||
timestamp.paint_with_fallback_color(
|
||||
&ui.painter().with_clip_rect(rect),
|
||||
Pos2 {
|
||||
x: rect.left() + label_width + 8.0,
|
||||
y: rect.top() + 6.0,
|
||||
},
|
||||
ui.visuals().weak_text_color(),
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
65
crates/packet_inspector/src/state.rs
Normal file
65
crates/packet_inspector/src/state.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use std::io::ErrorKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use valence_protocol::codec::{PacketDecoder, PacketEncoder};
|
||||
use valence_protocol::Packet as ValencePacket;
|
||||
|
||||
use crate::context::{Context, Packet, Stage};
|
||||
use crate::packet_widget::PacketDirection;
|
||||
|
||||
pub struct State {
|
||||
pub direction: PacketDirection,
|
||||
pub context: Arc<Context>,
|
||||
pub enc: PacketEncoder,
|
||||
pub dec: PacketDecoder,
|
||||
pub read: OwnedReadHalf,
|
||||
pub write: OwnedWriteHalf,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub async fn rw_packet<'a, P>(&'a mut self, stage: Stage) -> anyhow::Result<P>
|
||||
where
|
||||
P: ValencePacket<'a>,
|
||||
{
|
||||
while !self.dec.has_next_packet()? {
|
||||
self.dec.reserve(4096);
|
||||
let mut buf = self.dec.take_capacity();
|
||||
|
||||
if self.read.read_buf(&mut buf).await? == 0 {
|
||||
return Err(std::io::Error::from(ErrorKind::UnexpectedEof).into());
|
||||
}
|
||||
|
||||
self.dec.queue_bytes(buf);
|
||||
}
|
||||
|
||||
let has_compression = self.dec.compression();
|
||||
let pkt: P = self.dec.try_next_packet()?.unwrap();
|
||||
|
||||
self.enc.append_packet(&pkt)?;
|
||||
|
||||
let bytes = self.enc.take();
|
||||
self.write.write_all(&bytes).await?;
|
||||
|
||||
let time = match OffsetDateTime::now_local() {
|
||||
Ok(time) => time,
|
||||
Err(_) => OffsetDateTime::now_utc(),
|
||||
};
|
||||
|
||||
self.context.add(Packet {
|
||||
id: 0, // updated when added to context
|
||||
direction: self.direction.clone(),
|
||||
use_compression: has_compression,
|
||||
packet_data: bytes.to_vec(),
|
||||
stage,
|
||||
created_at: time,
|
||||
selected: false,
|
||||
packet_type: pkt.packet_id(),
|
||||
packet_name: pkt.packet_name().to_string(),
|
||||
});
|
||||
|
||||
Ok(pkt)
|
||||
}
|
||||
}
|
513
crates/packet_inspector/src/syntax_highlighting.rs
Normal file
513
crates/packet_inspector/src/syntax_highlighting.rs
Normal file
|
@ -0,0 +1,513 @@
|
|||
// From: https://github.com/emilk/egui/blob/master/crates/egui_demo_lib/src/syntax_highlighting.rs
|
||||
|
||||
use egui::text::LayoutJob;
|
||||
|
||||
/// View some code with syntax highlighting and selection.
|
||||
pub fn code_view_ui(ui: &mut egui::Ui, mut code: &str) {
|
||||
let language = "rs";
|
||||
let theme = CodeTheme::from_memory(ui.ctx());
|
||||
|
||||
let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| {
|
||||
let mut layout_job = highlight(ui.ctx(), &theme, string, language);
|
||||
layout_job.wrap.max_width = wrap_width; // no wrapping
|
||||
ui.fonts(|f| f.layout_job(layout_job))
|
||||
};
|
||||
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut code)
|
||||
.font(egui::TextStyle::Monospace) // for cursor height
|
||||
.code_editor()
|
||||
.desired_width(ui.available_width())
|
||||
.desired_rows(24)
|
||||
.lock_focus(true)
|
||||
.layouter(&mut layouter),
|
||||
);
|
||||
}
|
||||
|
||||
/// Memoized Code highlighting
|
||||
pub fn highlight(ctx: &egui::Context, theme: &CodeTheme, code: &str, language: &str) -> LayoutJob {
|
||||
impl egui::util::cache::ComputerMut<(&CodeTheme, &str, &str), LayoutJob> for Highlighter {
|
||||
fn compute(&mut self, (theme, code, lang): (&CodeTheme, &str, &str)) -> LayoutJob {
|
||||
self.highlight(theme, code, lang)
|
||||
}
|
||||
}
|
||||
|
||||
type HighlightCache = egui::util::cache::FrameCache<LayoutJob, Highlighter>;
|
||||
|
||||
ctx.memory_mut(|mem| {
|
||||
mem.caches
|
||||
.cache::<HighlightCache>()
|
||||
.get((theme, code, language))
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[derive(enum_map::Enum)]
|
||||
enum TokenType {
|
||||
Comment,
|
||||
Keyword,
|
||||
Literal,
|
||||
StringLiteral,
|
||||
Punctuation,
|
||||
Whitespace,
|
||||
}
|
||||
|
||||
#[cfg(feature = "syntect")]
|
||||
#[derive(Clone, Copy, Hash, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[allow(unused)]
|
||||
enum SyntectTheme {
|
||||
Base16EightiesDark,
|
||||
Base16MochaDark,
|
||||
Base16OceanDark,
|
||||
Base16OceanLight,
|
||||
InspiredGitHub,
|
||||
SolarizedDark,
|
||||
SolarizedLight,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[cfg(feature = "syntect")]
|
||||
impl SyntectTheme {
|
||||
fn all() -> impl ExactSizeIterator<Item = Self> {
|
||||
[
|
||||
Self::Base16EightiesDark,
|
||||
Self::Base16MochaDark,
|
||||
Self::Base16OceanDark,
|
||||
Self::Base16OceanLight,
|
||||
Self::InspiredGitHub,
|
||||
Self::SolarizedDark,
|
||||
Self::SolarizedLight,
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Base16EightiesDark => "Base16 Eighties (dark)",
|
||||
Self::Base16MochaDark => "Base16 Mocha (dark)",
|
||||
Self::Base16OceanDark => "Base16 Ocean (dark)",
|
||||
Self::Base16OceanLight => "Base16 Ocean (light)",
|
||||
Self::InspiredGitHub => "InspiredGitHub (light)",
|
||||
Self::SolarizedDark => "Solarized (dark)",
|
||||
Self::SolarizedLight => "Solarized (light)",
|
||||
}
|
||||
}
|
||||
|
||||
fn syntect_key_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Base16EightiesDark => "base16-eighties.dark",
|
||||
Self::Base16MochaDark => "base16-mocha.dark",
|
||||
Self::Base16OceanDark => "base16-ocean.dark",
|
||||
Self::Base16OceanLight => "base16-ocean.light",
|
||||
Self::InspiredGitHub => "InspiredGitHub",
|
||||
Self::SolarizedDark => "Solarized (dark)",
|
||||
Self::SolarizedLight => "Solarized (light)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dark(&self) -> bool {
|
||||
match self {
|
||||
Self::Base16EightiesDark
|
||||
| Self::Base16MochaDark
|
||||
| Self::Base16OceanDark
|
||||
| Self::SolarizedDark => true,
|
||||
|
||||
Self::Base16OceanLight | Self::InspiredGitHub | Self::SolarizedLight => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Hash, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub struct CodeTheme {
|
||||
dark_mode: bool,
|
||||
|
||||
#[cfg(feature = "syntect")]
|
||||
syntect_theme: SyntectTheme,
|
||||
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
formats: enum_map::EnumMap<TokenType, egui::TextFormat>,
|
||||
}
|
||||
|
||||
impl Default for CodeTheme {
|
||||
fn default() -> Self {
|
||||
Self::dark()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl CodeTheme {
|
||||
pub fn from_style(style: &egui::Style) -> Self {
|
||||
if style.visuals.dark_mode {
|
||||
Self::dark()
|
||||
} else {
|
||||
Self::light()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_memory(ctx: &egui::Context) -> Self {
|
||||
if ctx.style().visuals.dark_mode {
|
||||
ctx.data_mut(|d| {
|
||||
d.get_persisted(egui::Id::new("dark"))
|
||||
.unwrap_or_else(CodeTheme::dark)
|
||||
})
|
||||
} else {
|
||||
ctx.data_mut(|d| {
|
||||
d.get_persisted(egui::Id::new("light"))
|
||||
.unwrap_or_else(CodeTheme::light)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store_in_memory(self, ctx: &egui::Context) {
|
||||
if self.dark_mode {
|
||||
ctx.data_mut(|d| d.insert_persisted(egui::Id::new("dark"), self));
|
||||
} else {
|
||||
ctx.data_mut(|d| d.insert_persisted(egui::Id::new("light"), self));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[cfg(feature = "syntect")]
|
||||
impl CodeTheme {
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
dark_mode: true,
|
||||
syntect_theme: SyntectTheme::SolarizedDark,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn light() -> Self {
|
||||
Self {
|
||||
dark_mode: false,
|
||||
syntect_theme: SyntectTheme::SolarizedLight,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
egui::widgets::global_dark_light_mode_buttons(ui);
|
||||
|
||||
for theme in SyntectTheme::all() {
|
||||
if theme.is_dark() == self.dark_mode {
|
||||
ui.radio_value(&mut self.syntect_theme, theme, theme.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
impl CodeTheme {
|
||||
pub fn dark() -> Self {
|
||||
let font_id = egui::FontId::monospace(10.0);
|
||||
use egui::{Color32, TextFormat};
|
||||
Self {
|
||||
dark_mode: true,
|
||||
formats: enum_map::enum_map![
|
||||
TokenType::Comment => TextFormat::simple(font_id.clone(), Color32::from_gray(120)),
|
||||
TokenType::Keyword => TextFormat::simple(font_id.clone(), Color32::from_rgb(255, 100, 100)),
|
||||
TokenType::Literal => TextFormat::simple(font_id.clone(), Color32::from_rgb(87, 165, 171)),
|
||||
TokenType::StringLiteral => TextFormat::simple(font_id.clone(), Color32::from_rgb(109, 147, 226)),
|
||||
TokenType::Punctuation => TextFormat::simple(font_id.clone(), Color32::LIGHT_GRAY),
|
||||
TokenType::Whitespace => TextFormat::simple(font_id.clone(), Color32::TRANSPARENT),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn light() -> Self {
|
||||
let font_id = egui::FontId::monospace(10.0);
|
||||
use egui::{Color32, TextFormat};
|
||||
Self {
|
||||
dark_mode: false,
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
formats: enum_map::enum_map![
|
||||
TokenType::Comment => TextFormat::simple(font_id.clone(), Color32::GRAY),
|
||||
TokenType::Keyword => TextFormat::simple(font_id.clone(), Color32::from_rgb(235, 0, 0)),
|
||||
TokenType::Literal => TextFormat::simple(font_id.clone(), Color32::from_rgb(153, 134, 255)),
|
||||
TokenType::StringLiteral => TextFormat::simple(font_id.clone(), Color32::from_rgb(37, 203, 105)),
|
||||
TokenType::Punctuation => TextFormat::simple(font_id.clone(), Color32::DARK_GRAY),
|
||||
TokenType::Whitespace => TextFormat::simple(font_id.clone(), Color32::TRANSPARENT),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal_top(|ui| {
|
||||
let selected_id = egui::Id::null();
|
||||
let mut selected_tt: TokenType =
|
||||
ui.data_mut(|d| *d.get_persisted_mut_or(selected_id, TokenType::Comment));
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(150.0);
|
||||
egui::widgets::global_dark_light_mode_buttons(ui);
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.scope(|ui| {
|
||||
for (tt, tt_name) in [
|
||||
(TokenType::Comment, "// comment"),
|
||||
(TokenType::Keyword, "keyword"),
|
||||
(TokenType::Literal, "literal"),
|
||||
(TokenType::StringLiteral, "\"string literal\""),
|
||||
(TokenType::Punctuation, "punctuation ;"),
|
||||
// (TokenType::Whitespace, "whitespace"),
|
||||
] {
|
||||
let format = &mut self.formats[tt];
|
||||
ui.style_mut().override_font_id = Some(format.font_id.clone());
|
||||
ui.visuals_mut().override_text_color = Some(format.color);
|
||||
ui.radio_value(&mut selected_tt, tt, tt_name);
|
||||
}
|
||||
});
|
||||
|
||||
let reset_value = if self.dark_mode {
|
||||
CodeTheme::dark()
|
||||
} else {
|
||||
CodeTheme::light()
|
||||
};
|
||||
|
||||
if ui
|
||||
.add_enabled(*self != reset_value, egui::Button::new("Reset theme"))
|
||||
.clicked()
|
||||
{
|
||||
*self = reset_value;
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
ui.data_mut(|d| d.insert_persisted(selected_id, selected_tt));
|
||||
|
||||
egui::Frame::group(ui.style())
|
||||
.inner_margin(egui::Vec2::splat(2.0))
|
||||
.show(ui, |ui| {
|
||||
// ui.group(|ui| {
|
||||
ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
|
||||
ui.spacing_mut().slider_width = 128.0; // Controls color picker size
|
||||
egui::widgets::color_picker::color_picker_color32(
|
||||
ui,
|
||||
&mut self.formats[selected_tt].color,
|
||||
egui::color_picker::Alpha::Opaque,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "syntect")]
|
||||
struct Highlighter {
|
||||
ps: syntect::parsing::SyntaxSet,
|
||||
ts: syntect::highlighting::ThemeSet,
|
||||
}
|
||||
|
||||
#[cfg(feature = "syntect")]
|
||||
impl Default for Highlighter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ps: syntect::parsing::SyntaxSet::load_defaults_newlines(),
|
||||
ts: syntect::highlighting::ThemeSet::load_defaults(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "syntect")]
|
||||
impl Highlighter {
|
||||
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
|
||||
fn highlight(&self, theme: &CodeTheme, code: &str, lang: &str) -> LayoutJob {
|
||||
self.highlight_impl(theme, code, lang).unwrap_or_else(|| {
|
||||
// Fallback:
|
||||
LayoutJob::simple(
|
||||
code.into(),
|
||||
egui::FontId::monospace(12.0),
|
||||
if theme.dark_mode {
|
||||
egui::Color32::LIGHT_GRAY
|
||||
} else {
|
||||
egui::Color32::DARK_GRAY
|
||||
},
|
||||
f32::INFINITY,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn highlight_impl(&self, theme: &CodeTheme, text: &str, language: &str) -> Option<LayoutJob> {
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::FontStyle;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
let syntax = self
|
||||
.ps
|
||||
.find_syntax_by_name(language)
|
||||
.or_else(|| self.ps.find_syntax_by_extension(language))?;
|
||||
|
||||
let theme = theme.syntect_theme.syntect_key_name();
|
||||
let mut h = HighlightLines::new(syntax, &self.ts.themes[theme]);
|
||||
|
||||
use egui::text::{LayoutSection, TextFormat};
|
||||
|
||||
let mut job = LayoutJob {
|
||||
text: text.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for line in LinesWithEndings::from(text) {
|
||||
for (style, range) in h.highlight_line(line, &self.ps).ok()? {
|
||||
let fg = style.foreground;
|
||||
let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b);
|
||||
let italics = style.font_style.contains(FontStyle::ITALIC);
|
||||
let underline = style.font_style.contains(FontStyle::ITALIC);
|
||||
let underline = if underline {
|
||||
egui::Stroke::new(1.0, text_color)
|
||||
} else {
|
||||
egui::Stroke::NONE
|
||||
};
|
||||
job.sections.push(LayoutSection {
|
||||
leading_space: 0.0,
|
||||
byte_range: as_byte_range(text, range),
|
||||
format: TextFormat {
|
||||
font_id: egui::FontId::monospace(12.0),
|
||||
color: text_color,
|
||||
italics,
|
||||
underline,
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some(job)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "syntect")]
|
||||
fn as_byte_range(whole: &str, range: &str) -> std::ops::Range<usize> {
|
||||
let whole_start = whole.as_ptr() as usize;
|
||||
let range_start = range.as_ptr() as usize;
|
||||
assert!(whole_start <= range_start);
|
||||
assert!(range_start + range.len() <= whole_start + whole.len());
|
||||
let offset = range_start - whole_start;
|
||||
offset..(offset + range.len())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
#[derive(Default)]
|
||||
struct Highlighter {}
|
||||
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
impl Highlighter {
|
||||
#[allow(clippy::unused_self, clippy::unnecessary_wraps)]
|
||||
fn highlight(&self, theme: &CodeTheme, mut text: &str, _language: &str) -> LayoutJob {
|
||||
// Extremely simple syntax highlighter for when we compile without syntect
|
||||
|
||||
let mut job = LayoutJob::default();
|
||||
|
||||
while !text.is_empty() {
|
||||
if text.starts_with("//") {
|
||||
let end = text.find('\n').unwrap_or(text.len());
|
||||
job.append(&text[..end], 0.0, theme.formats[TokenType::Comment].clone());
|
||||
text = &text[end..];
|
||||
} else if text.starts_with('"') {
|
||||
let end = text[1..]
|
||||
.find('"')
|
||||
.map(|i| i + 2)
|
||||
.or_else(|| text.find('\n'))
|
||||
.unwrap_or(text.len());
|
||||
job.append(
|
||||
&text[..end],
|
||||
0.0,
|
||||
theme.formats[TokenType::StringLiteral].clone(),
|
||||
);
|
||||
text = &text[end..];
|
||||
} else if text.starts_with(|c: char| c.is_ascii_alphanumeric()) {
|
||||
let end = text[1..]
|
||||
.find(|c: char| !c.is_ascii_alphanumeric())
|
||||
.map_or_else(|| text.len(), |i| i + 1);
|
||||
let word = &text[..end];
|
||||
let tt = if is_keyword(word) {
|
||||
TokenType::Keyword
|
||||
} else {
|
||||
TokenType::Literal
|
||||
};
|
||||
job.append(word, 0.0, theme.formats[tt].clone());
|
||||
text = &text[end..];
|
||||
} else if text.starts_with(|c: char| c.is_ascii_whitespace()) {
|
||||
let end = text[1..]
|
||||
.find(|c: char| !c.is_ascii_whitespace())
|
||||
.map_or_else(|| text.len(), |i| i + 1);
|
||||
job.append(
|
||||
&text[..end],
|
||||
0.0,
|
||||
theme.formats[TokenType::Whitespace].clone(),
|
||||
);
|
||||
text = &text[end..];
|
||||
} else {
|
||||
let mut it = text.char_indices();
|
||||
it.next();
|
||||
let end = it.next().map_or(text.len(), |(idx, _chr)| idx);
|
||||
job.append(
|
||||
&text[..end],
|
||||
0.0,
|
||||
theme.formats[TokenType::Punctuation].clone(),
|
||||
);
|
||||
text = &text[end..];
|
||||
}
|
||||
}
|
||||
|
||||
job
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "syntect"))]
|
||||
fn is_keyword(word: &str) -> bool {
|
||||
matches!(
|
||||
word,
|
||||
"as" | "async"
|
||||
| "await"
|
||||
| "break"
|
||||
| "const"
|
||||
| "continue"
|
||||
| "crate"
|
||||
| "dyn"
|
||||
| "else"
|
||||
| "enum"
|
||||
| "extern"
|
||||
| "false"
|
||||
| "fn"
|
||||
| "for"
|
||||
| "if"
|
||||
| "impl"
|
||||
| "in"
|
||||
| "let"
|
||||
| "loop"
|
||||
| "match"
|
||||
| "mod"
|
||||
| "move"
|
||||
| "mut"
|
||||
| "pub"
|
||||
| "ref"
|
||||
| "return"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "static"
|
||||
| "struct"
|
||||
| "super"
|
||||
| "trait"
|
||||
| "true"
|
||||
| "type"
|
||||
| "unsafe"
|
||||
| "use"
|
||||
| "where"
|
||||
| "while"
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue