minect/
connect.rs

1// Minect is library that allows a program to connect to a running Minecraft instance without
2// requiring any Minecraft mods.
3//
4// © Copyright (C) 2021-2023 Adrodoc <adrodoc55@googlemail.com> & skess42 <skagaros@gmail.com>
5//
6// This file is part of Minect.
7//
8// Minect is free software: you can redistribute it and/or modify it under the terms of the GNU
9// General Public License as published by the Free Software Foundation, either version 3 of the
10// License, or (at your option) any later version.
11//
12// Minect is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
13// the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
14// Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along with Minect.
17// If not, see <http://www.gnu.org/licenses/>.
18
19use crate::{
20    command::{summon_named_entity_command, AddTagOutput, SummonNamedEntityOutput},
21    io::{create_dir_all, io_error, remove_dir, remove_dir_all, write, IoErrorAtPath},
22    log::LogEvent,
23    on_drop::OnDrop,
24    read_incremented_id, Command, ExecuteCommandsError, ExecuteCommandsErrorInner,
25    MinecraftConnection,
26};
27use indexmap::IndexSet;
28use log::error;
29use serde::{Deserialize, Serialize};
30use std::{
31    fmt::Display,
32    fs::{File, OpenOptions},
33    io::{BufReader, BufWriter, Seek, SeekFrom},
34    path::Path,
35    sync::atomic::{AtomicBool, Ordering},
36};
37use tokio_stream::StreamExt;
38use walkdir::WalkDir;
39
40/// The error returned from [MinecraftConnection::connect].
41#[derive(Debug)]
42pub struct ConnectError {
43    inner: ConnectErrorInner,
44}
45#[derive(Debug)]
46enum ConnectErrorInner {
47    Io(IoErrorAtPath),
48    Cancelled,
49}
50impl ConnectError {
51    fn new(inner: ConnectErrorInner) -> ConnectError {
52        ConnectError { inner }
53    }
54
55    /// Returns `true` if [connect](MinecraftConnection::connect) failed because the player
56    /// cancelled the installation in the interactive installer.
57    pub fn is_cancelled(&self) -> bool {
58        matches!(self.inner, ConnectErrorInner::Cancelled)
59    }
60}
61impl From<IoErrorAtPath> for ConnectError {
62    fn from(value: IoErrorAtPath) -> ConnectError {
63        ConnectError::new(ConnectErrorInner::Io(value))
64    }
65}
66impl From<ExecuteCommandsError> for ConnectError {
67    fn from(value: ExecuteCommandsError) -> ConnectError {
68        match value.inner {
69            ExecuteCommandsErrorInner::Io(error) => error.into(),
70        }
71    }
72}
73impl Display for ConnectError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match &self.inner {
76            ConnectErrorInner::Io(error) => error.fmt(f),
77            ConnectErrorInner::Cancelled => write!(f, "Cancelled"),
78        }
79    }
80}
81impl std::error::Error for ConnectError {}
82impl From<ConnectError> for std::io::Error {
83    fn from(value: ConnectError) -> std::io::Error {
84        match value.inner {
85            ConnectErrorInner::Io(error) => std::io::Error::from(error),
86            ConnectErrorInner::Cancelled => {
87                std::io::Error::new(std::io::ErrorKind::ConnectionRefused, value)
88            }
89        }
90    }
91}
92
93pub(crate) async fn connect(connection: &mut MinecraftConnection) -> Result<(), ConnectError> {
94    connection.create_datapack()?;
95
96    let success = AtomicBool::new(false);
97    let identifier = connection.identifier.clone();
98    let datapack_dir = connection.datapack_dir.clone();
99    // Has to be stored to a variable that is not named _ to ensure it is dropped at the end of the function and not right away.
100    let on_drop = OnDrop::new(|| {
101        // TODO: use block_on to allow concurrency
102        remove_connector(&identifier, &datapack_dir);
103        if !success.load(Ordering::Relaxed) {
104            remove_disconnector(&identifier, &datapack_dir);
105        }
106        remove_empty_dirs(&datapack_dir);
107    });
108
109    let structure_id = {
110        let path = connection.structures_dir.join("id.txt");
111        match File::open(&path) {
112            Ok(mut file) => read_incremented_id(&mut file, &path)?,
113            Err(_) => 0,
114        }
115    };
116
117    create_connector(&identifier, structure_id, &datapack_dir)?;
118    create_disconnector(&identifier, &datapack_dir)?;
119    wait_for_connection(connection).await?;
120    success.store(true, Ordering::Relaxed);
121    drop(on_drop);
122    connection.execute_commands([Command::new(format!(
123        "function minect_internal:connection/{}/connect/reload",
124        identifier
125    ))])?;
126
127    Ok(())
128}
129
130fn create_connector(
131    identifier: &str,
132    structure_id: u64,
133    datapack_dir: impl AsRef<Path>,
134) -> Result<(), IoErrorAtPath> {
135    let expand_template = |template: &str| {
136        expand_template(template, identifier).replace("-structure_id-", &structure_id.to_string())
137    };
138    let datapack_dir = datapack_dir.as_ref();
139
140    macro_rules! add_to_function_tag {
141        ($relative_path:expr) => {{
142            let path = datapack_dir.join($relative_path);
143            let template = expand_template(include_datapack_template!($relative_path));
144            add_to_function_tag(path, &template)
145        }};
146    }
147    add_to_function_tag!("data/minect_internal/tags/functions/connect/choose_chunk.json")?;
148    add_to_function_tag!("data/minect_internal/tags/functions/connect/prompt.json")?;
149
150    macro_rules! expand {
151        ($relative_path:expr) => {{
152            let path = datapack_dir.join(expand_template($relative_path));
153            let contents = expand_template(include_datapack_template!($relative_path));
154            write(path, &contents)
155        }};
156    }
157    expand!("data/minect_internal/functions/connection/-connection_id-/connect/cancel_cleanup.mcfunction")?;
158    expand!("data/minect_internal/functions/connection/-connection_id-/connect/cancel.mcfunction")?;
159    expand!(
160        "data/minect_internal/functions/connection/-connection_id-/connect/choose_chunk_unchecked.mcfunction"
161    )?;
162    expand!(
163        "data/minect_internal/functions/connection/-connection_id-/connect/choose_chunk.mcfunction"
164    )?;
165    expand!("data/minect_internal/functions/connection/-connection_id-/connect/confirm_chunk.mcfunction")?;
166    expand!("data/minect_internal/functions/connection/-connection_id-/connect/prompt_unchecked.mcfunction")?;
167    expand!("data/minect_internal/functions/connection/-connection_id-/connect/prompt.mcfunction")?;
168    expand!("data/minect_internal/functions/connection/-connection_id-/connect/reload.mcfunction")?;
169
170    Ok(())
171}
172
173fn remove_connector(identifier: &str, datapack_dir: impl AsRef<Path>) {
174    let expand_template = |template: &str| expand_template(template, identifier);
175    let datapack_dir = datapack_dir.as_ref();
176
177    macro_rules! remove_from_function_tag {
178        ($relative_path:expr) => {{
179            let path = datapack_dir.join($relative_path);
180            let template = expand_template(include_datapack_template!($relative_path));
181            log_cleanup_error(remove_from_function_tag(path, &template))
182        }};
183    }
184    remove_from_function_tag!("data/minect_internal/tags/functions/connect/choose_chunk.json");
185    remove_from_function_tag!("data/minect_internal/tags/functions/connect/prompt.json");
186
187    let remove = |template_path| {
188        let path = datapack_dir.join(expand_template(template_path));
189        log_cleanup_error(remove_dir_all(path));
190    };
191    remove("data/minect_internal/functions/connection/-connection_id-/connect");
192}
193
194fn create_disconnector(
195    identifier: &str,
196    datapack_dir: impl AsRef<Path>,
197) -> Result<(), IoErrorAtPath> {
198    let expand_template = |template: &str| expand_template(template, identifier);
199    let datapack_dir = datapack_dir.as_ref();
200
201    macro_rules! add_to_function_tag {
202        ($relative_path:expr) => {{
203            let path = datapack_dir.join($relative_path);
204            let template = expand_template(include_datapack_template!($relative_path));
205            add_to_function_tag(path, &template)
206        }};
207    }
208    add_to_function_tag!("data/minect_internal/tags/functions/disconnect/prompt.json")?;
209
210    macro_rules! expand {
211        ($relative_path:expr) => {{
212            let path = datapack_dir.join(expand_template($relative_path));
213            let contents = expand_template(include_datapack_template!($relative_path));
214            write(path, &contents)
215        }};
216    }
217    expand!(
218        "data/minect_internal/functions/connection/-connection_id-/disconnect/prompt.mcfunction"
219    )?;
220
221    Ok(())
222}
223
224fn remove_disconnector(identifier: &str, datapack_dir: impl AsRef<Path>) {
225    let expand_template = |template: &str| expand_template(template, identifier);
226    let datapack_dir = datapack_dir.as_ref();
227
228    macro_rules! remove_from_function_tag {
229        ($relative_path:expr) => {{
230            let path = datapack_dir.join($relative_path);
231            let template = expand_template(include_datapack_template!($relative_path));
232            log_cleanup_error(remove_from_function_tag(path, &template))
233        }};
234    }
235    remove_from_function_tag!("data/minect_internal/tags/functions/disconnect/prompt.json");
236
237    let remove = |template_path| {
238        let path = datapack_dir.join(expand_template(template_path));
239        log_cleanup_error(remove_dir_all(path));
240    };
241    remove("data/minect_internal/functions/connection/-connection_id-/disconnect");
242}
243
244fn remove_empty_dirs(datapack_dir: impl AsRef<Path>) {
245    for entry in WalkDir::new(&datapack_dir).contents_first(true) {
246        if let Ok(entry) = entry {
247            if entry.file_type().is_dir() {
248                let _ = remove_dir(entry.path());
249            }
250        }
251    }
252}
253
254// If we fail to clean something up we still want to try to clean up the rest
255fn log_cleanup_error(result: Result<(), impl Display>) {
256    if let Err(e) = result {
257        error!("Failed to clean up after connect: {}", e)
258    }
259}
260
261fn expand_template(template: &str, identifier: &str) -> String {
262    template.replace("-connection_id-", identifier)
263}
264
265async fn wait_for_connection(connection: &mut MinecraftConnection) -> Result<(), ConnectError> {
266    const CONNECT_OUTPUT_PREFIX: &str = "minect_connect_";
267    const LISTENER_NAME: &str = "minect_connect";
268
269    let events = connection.add_named_listener(LISTENER_NAME);
270
271    connection.execute_commands([Command::named(
272        LISTENER_NAME,
273        summon_named_entity_command(&format!("{}success", CONNECT_OUTPUT_PREFIX)),
274    )])?;
275
276    enum Output {
277        Success,
278        Cancelled,
279    }
280    impl TryFrom<LogEvent> for Output {
281        type Error = ();
282        fn try_from(event: LogEvent) -> Result<Self, Self::Error> {
283            let output = if let Ok(output) = event.output.parse::<SummonNamedEntityOutput>() {
284                output.name
285            } else if let Ok(output) = event.output.parse::<AddTagOutput>() {
286                output.tag
287            } else {
288                return Err(());
289            };
290
291            match output.strip_prefix(CONNECT_OUTPUT_PREFIX) {
292                Some("success") => Ok(Output::Success),
293                Some("cancelled") => Ok(Output::Cancelled),
294                _ => Err(()),
295            }
296        }
297    }
298    let output = events
299        .filter_map(|event| event.try_into().ok())
300        .next()
301        .await;
302    match output.expect("LogObserver panicked") {
303        Output::Success => Ok(()),
304        Output::Cancelled => Err(ConnectError::new(ConnectErrorInner::Cancelled)),
305    }
306}
307
308#[derive(Debug, Deserialize, Serialize)]
309struct FunctionTag {
310    #[serde(default)]
311    values: IndexSet<String>,
312}
313impl FunctionTag {
314    fn new() -> FunctionTag {
315        FunctionTag {
316            values: IndexSet::new(),
317        }
318    }
319}
320
321fn add_to_function_tag(path: impl AsRef<Path>, template: &str) -> Result<(), IoErrorAtPath> {
322    let tag_template: FunctionTag = serde_json::from_str(template).unwrap(); // Our templates are valid, so this can't fail
323
324    modify_function_tag(path, |tag| {
325        tag.values.extend(tag_template.values);
326    })
327}
328
329fn remove_from_function_tag(path: impl AsRef<Path>, template: &str) -> Result<(), IoErrorAtPath> {
330    let tag_template: FunctionTag = serde_json::from_str(template).unwrap(); // Our templates are valid, so this can't fail
331
332    modify_function_tag(path, |tag| {
333        tag.values
334            .retain(|value| !tag_template.values.contains(value))
335    })
336}
337
338fn modify_function_tag(
339    path: impl AsRef<Path>,
340    modify: impl FnOnce(&mut FunctionTag),
341) -> Result<(), IoErrorAtPath> {
342    let (mut tag, mut file) = read_function_tag(&path)?;
343    let old_len = tag.values.len();
344    modify(&mut tag);
345    let modified = old_len != tag.values.len();
346    if modified {
347        write_function_tag(&tag, &mut file, path)?;
348    }
349    Ok(())
350}
351
352fn read_function_tag(path: &impl AsRef<Path>) -> Result<(FunctionTag, File), IoErrorAtPath> {
353    if let Some(parent) = path.as_ref().parent() {
354        create_dir_all(parent)?;
355    }
356    let file = OpenOptions::new()
357        .create(true)
358        .read(true)
359        .write(true)
360        .open(path)
361        .map_err(io_error("Failed to open file", path.as_ref()))?;
362    let reader = BufReader::new(&file);
363    let tag = match serde_json::from_reader(reader) {
364        Ok(tag) => tag,
365        Err(e) if e.is_eof() && e.line() == 1 && e.column() == 0 => FunctionTag::new(),
366        Err(e) => return Err(IoErrorAtPath::new("Failed to parse file", path.as_ref(), e)),
367    };
368    Ok((tag, file))
369}
370
371fn write_function_tag(
372    tag: &FunctionTag,
373    file: &mut File,
374    path: impl AsRef<Path>,
375) -> Result<(), IoErrorAtPath> {
376    file.set_len(0)
377        .map_err(io_error("Failed to truncate file", path.as_ref()))?;
378    file.seek(SeekFrom::Start(0))
379        .map_err(io_error("Failed to seek beginning of file", path.as_ref()))?;
380    let mut writer = BufWriter::new(file);
381    serde_json::to_writer_pretty(&mut writer, tag)
382        .map_err(io_error("Failed to write to file", path.as_ref()))
383}