Skip to main content

chilen_daemon/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(docsrs, feature(doc_auto_cfg))]
3#![feature(doc_cfg)]
4
5mod daemon_thread;
6mod music_lib;
7pub mod playback;
8#[cfg(test)]
9mod tests;
10
11use std::{
12    env::{home_dir, temp_dir},
13    fs::remove_file,
14    path::PathBuf,
15    sync::{Arc, LazyLock, RwLock, mpsc},
16    thread::{self, JoinHandle},
17};
18
19use interprocess::local_socket::{Listener, ListenerOptions, Stream, traits::ListenerExt};
20use log::{debug, error, info, trace, warn};
21
22use chilen_ipc::{Command, DEFAULT_SOCKET_NAME, Event, Response};
23use serde::{Deserialize, Serialize};
24
25use crate::{
26    daemon_thread::ThreadCommand,
27    music_lib::{
28        CACHE_DIR,
29        covers::LoadMode,
30        state::{self, get_library},
31    },
32};
33
34/// Defines the socket type to use when starting the daemon.
35pub type SocketType = chilen_ipc::SocketType;
36
37static EVENT_SENDERS: LazyLock<Arc<RwLock<Vec<mpsc::Sender<Event>>>>> =
38    LazyLock::new(|| Arc::new(RwLock::new(Vec::new())));
39
40/// Damon thread to main daemon process communication.
41static COMMAND_SENDER: LazyLock<Arc<RwLock<Option<mpsc::Sender<ThreadCommand>>>>> =
42    LazyLock::new(|| Arc::new(RwLock::new(None)));
43
44/// This property will always be set during daemon runtime, it is mostly safe to unwrap it in
45/// functions launched by the daemon.
46pub(crate) static CONFIG: LazyLock<Arc<RwLock<Option<Config>>>> =
47    LazyLock::new(|| Arc::new(RwLock::new(None)));
48
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub enum Error {
51    /// Socket creation failed.
52    SocketError,
53    /// The socket address is already in use.
54    AddrInUse,
55    /// Emitted when the daemon event channel is already initialized when starting the daemon.
56    ///
57    /// This likely means a second daemon was started in the same context.
58    EventChannelInitialized,
59    /// The event channel is not initialized, which likely means that the daemon isn't running.
60    DaemonNotRunning,
61    /// The provided music library path is not a directory or doesn't exist.
62    NoLibrary,
63    /// Cannot access the music library due to a permission issue.
64    LibraryNotAccessible,
65    /// The cache directory could not be initialized.
66    ///
67    /// This is usually a result of a permission issue.
68    CacheDirError(String),
69    /// The data directory could not be initialized.
70    ///
71    /// This is usually a result of a permission issue.
72    DataDirError(String),
73    /// Quit requests from external clients are not allowed.
74    QuitDisabled,
75    /// Raise requests are not allowed.
76    RaiseDisabled,
77    /// Toggling fullscreen mode by external clients is not allowed.
78    SetFullscreenDisabled,
79}
80
81/// Request sent from a client forwarded to the daemon.
82#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
83pub enum Request {
84    /// Bring the music player’s user interface to the front using any appropriate mechanism
85    /// available.
86    Raise,
87    /// Set whether the music player's user interface is displayed in full screen mode.
88    SetFullscreen(bool),
89}
90
91impl std::fmt::Display for Error {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::SocketError => write!(f, "Socket creation failed"),
95            Self::AddrInUse => write!(f, "The socket address is already in use"),
96            Self::EventChannelInitialized => {
97                write!(f, "The event channel is already initialized")
98            }
99            Self::DaemonNotRunning => write!(f, "The daemon doesn't seem to be running"),
100            Self::NoLibrary => {
101                write!(
102                    f,
103                    "The provided music library path is not a directory or doesn't exist"
104                )
105            }
106            Self::LibraryNotAccessible => write!(
107                f,
108                "Could not access the music library due to a permission issue"
109            ),
110            Self::CacheDirError(e) => write!(f, "Could not initialize the cache directory: {e}"),
111            Self::DataDirError(e) => write!(f, "Could not initialize the data directory: {e}"),
112            Self::QuitDisabled => write!(f, "Quit requests from external clients are not allowed"),
113            Self::RaiseDisabled => write!(f, "Raise requests are not allowed"),
114            Self::SetFullscreenDisabled => write!(
115                f,
116                "Toggling fullscreen mode by external clients is not allowed"
117            ),
118        }
119    }
120}
121
122/// Defines under which conditions should the daemon claim an occupied socket address.
123///
124/// This is only effective if filesystem sockets are used.
125#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
126pub enum AddrClaimMode {
127    /// Do not claim the socket address under any circumstances.
128    DoNotClaim,
129    /// Only claim the socket address if there is no response to ping commands from the process
130    /// that listens on the socket.
131    #[default]
132    ClaimIfUnresponsive,
133    /// Force claim the socket address.
134    ForceClaim,
135}
136
137/// Error originating from the [`Config`] struct.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
139pub enum ConfigError {
140    /// The provided bus name suffix for MPRIS was invalid.
141    InvalidBusNameSuffix,
142    /// Could not get the home directory path.
143    HomeError,
144}
145
146impl std::fmt::Display for ConfigError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::InvalidBusNameSuffix => write!(f, "The bus name suffix provided was invalid"),
150            Self::HomeError => write!(f, "Could not get the home directory path"),
151        }
152    }
153}
154
155// TODO: Add a config option for reshuffling tracks on playlist repeat or something, will figure it out
156/// Configuration options for the daemon.
157///
158/// Used with the [`start`] function.
159#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
160pub struct Config {
161    /// The directory containing daemon cache.
162    pub cache_dir: PathBuf,
163    /// The directory containing the program data, eg. the playlist file.
164    pub data_dir: PathBuf,
165    /// The directory containing the music library/audio files to load.
166    pub music_dir: PathBuf,
167    /// The name of the socket to listen on.
168    ///
169    /// This will either resolve to a namespaced socket with the same name, or, in case of a
170    /// filesystem socket, a file with the same name in the temporary directory.
171    pub socket_name: String,
172    /// Defines under which conditions should the daemon claim an occupied socket address.
173    pub addr_claim_mode: AddrClaimMode,
174    /// Defines the type of socket the daemon should use.
175    pub socket_type: SocketType,
176    /// Whether the player's user interface can be brought to the front using any appropriate
177    /// mechanism available.
178    ///
179    /// Players that have no ability to raise (eg. players CLI or TUI interfaces) should set this to
180    /// false.
181    pub can_raise: bool,
182    // Whether clients can request the daemon to display the user interface in fullscreen mode.
183    pub can_set_fullscreen: bool,
184    /// Whether clients can request the daemon to quit.
185    ///
186    /// This only affects clients that connect to the daemon over a local socket, it does not
187    /// affect the [`quit`] function.
188    pub can_quit: bool,
189    /// The basename of an installed .desktop file which complies with the Desktop entry
190    /// specification, with the ".desktop" extension stripped.
191    #[cfg(feature = "mpris")]
192    pub desktop_entry: Option<String>,
193    /// Configuration options specific to the [`playback`] module.
194    pub playback_config: playback::Config,
195}
196
197impl Config {
198    /// Get the default daemon config.
199    ///
200    /// For a custom music player, you probably want to create your own config from
201    /// scratch, or use the [`Config::try_from_name`] constructor.
202    ///
203    /// # Examples
204    /// ```
205    /// # use chilen_daemon;
206    /// let conf = chilen_daemon::Config::try_default().unwrap();
207    /// ```
208    pub fn try_default() -> Result<Self, ConfigError> {
209        Self::try_from_name("my-player", DEFAULT_SOCKET_NAME)
210    }
211
212    /// Create a new config from program and socket names.
213    ///
214    /// The generated paths will contain the name of the program, eg. `~/.cache/<PROGRAM_NAME>`,
215    /// `~/.local/share/<PROGRAM_NAME>`.
216    ///
217    /// Fails if the path to the home directory cannot be obtained.
218    ///
219    /// # Examples
220    /// ```
221    /// # use chilen_daemon;
222    /// let conf = chilen_daemon::Config::try_from_name("my-player", "MY_PLAYER");
223    /// ```
224    pub fn try_from_name(name: &str, socket_name: &str) -> Result<Self, ConfigError> {
225        let home_dir = match home_dir() {
226            Some(home) => home,
227            None => {
228                return Err(ConfigError::HomeError);
229            }
230        };
231
232        let mut cache_dir = home_dir.clone();
233        cache_dir.push(".cache");
234        cache_dir.push(name);
235
236        let mut data_dir = home_dir.clone();
237        data_dir.push(".local/share");
238        data_dir.push(name);
239
240        let mut music_dir = home_dir.clone();
241        music_dir.push("Music");
242
243        #[cfg(feature = "mpris")]
244        let bus_name_suffix = String::from("com.dev.") + name;
245        #[cfg(feature = "mpris")]
246        if !bus_name_suffix.is_ascii() {
247            return Err(ConfigError::InvalidBusNameSuffix);
248        }
249
250        Ok(Config {
251            cache_dir,
252            music_dir,
253            data_dir,
254            socket_name: socket_name.to_string(),
255            addr_claim_mode: AddrClaimMode::default(),
256            socket_type: SocketType::default(),
257            can_raise: false,
258            can_quit: true,
259            can_set_fullscreen: false,
260            #[cfg(feature = "mpris")]
261            desktop_entry: None,
262            playback_config: playback::Config {
263                #[cfg(feature = "mpris")]
264                identity: name.to_string(),
265                #[cfg(feature = "mpris")]
266                bus_name_suffix,
267                allow_rate_modification: false,
268            },
269        })
270    }
271}
272
273/// Clean up resources on shutdown.
274fn cleanup() {
275    trace!("Cleaning up...");
276    music_lib::cleanup(); // The MPRIS server must go first because it unwraps `CONFIG`
277    state::cleanup();
278    playback::cleanup();
279    playback::state::cleanup();
280    *COMMAND_SENDER.write().unwrap() = None;
281    *EVENT_SENDERS.write().unwrap() = Vec::new();
282    *CONFIG.write().unwrap() = None;
283    trace!("Done cleaning up");
284}
285
286fn handle_error(conn: std::io::Result<Stream>) -> Option<Stream> {
287    match conn {
288        Ok(c) => Some(c),
289        Err(e) => {
290            warn!("Incoming connection failed: {e}");
291            None
292        }
293    }
294}
295
296/// Returns a filesystem path for the given socket name.
297fn get_fs_socket_path(socket_name: &str) -> PathBuf {
298    let mut temp_dir = temp_dir();
299    temp_dir.push(socket_name);
300    temp_dir
301}
302
303/// Create an IPC socket listener for the daemon with the specified address.
304///
305/// Depending on the configuration, this can either return a namespaced socket or a filesystem one.
306///
307/// A filesystem socket will be returned if namespaced sockets are not supported, and the
308/// `socket_type` value passed is [`SocketType::NamespacedOrFilesystem`], or if the `socket_type`
309/// value passed is [`SocketType::FilesystemOnly`].
310///
311/// The [`AddrClaimMode`] value defines under which conditions should an occupied socket address be
312/// claimed. This is only effective for filesystem sockets.
313fn get_listener(
314    socket_name: &str,
315    socket_type: &SocketType,
316    claim_mode: &AddrClaimMode,
317) -> Result<Listener, Error> {
318    let socket = match chilen_ipc::get_socket(socket_name, socket_type) {
319        Ok(sock) => sock,
320        Err(e) => {
321            error!("Could not obtain the socket: {e}");
322            return Err(Error::SocketError);
323        }
324    };
325
326    let opts = ListenerOptions::new().name(socket.clone());
327    if socket.is_namespaced() {
328        trace!(
329            "Creating a listener on \"{}\" (namespaced socket)",
330            socket_name
331        );
332    } else {
333        trace!(
334            "Creating a listener on \"{}\" (filesystem socket)",
335            socket_name
336        );
337    }
338
339    match opts.create_sync() {
340        Ok(listener) => Ok(listener),
341        Err(e) => {
342            if e.kind() == std::io::ErrorKind::AddrInUse
343                && socket.is_path()
344                && !socket.is_namespaced()
345            {
346                warn!("The socket address is already in use");
347
348                match claim_mode {
349                    AddrClaimMode::DoNotClaim => {
350                        info!(
351                            "The daemon is configured not to reclaim the socket address, aborting"
352                        );
353                        Err(Error::AddrInUse)
354                    }
355                    AddrClaimMode::ClaimIfUnresponsive => {
356                        info!("Attempting to claim the socket address");
357                        match chilen_ipc::send_command(Command::Ping, socket_name, socket_type) {
358                            Ok(response) => {
359                                if response == Response::Pong {
360                                    error!(
361                                        "The other daemon responded to the pong command, aborting"
362                                    );
363                                    return Err(Error::AddrInUse);
364                                } else {
365                                    error!(
366                                        "Got an unexpected response from the daemon: {response:?}"
367                                    );
368                                    return Err(Error::AddrInUse);
369                                }
370                            }
371                            Err(e) => {
372                                if e == chilen_ipc::Error::ConnectionError {
373                                    info!(
374                                        "The other daemon is either dead or unresponsive, claiming the address"
375                                    );
376                                } else {
377                                    error!(
378                                        "Got an unexpected error while sending a ping command: {e}"
379                                    );
380                                    return Err(Error::AddrInUse);
381                                }
382                            }
383                        }
384
385                        if let Err(e) = remove_file(get_fs_socket_path(socket_name)) {
386                            error!("Could not remove the old socket: {e}");
387                            return Err(Error::SocketError);
388                        }
389                        let opts = ListenerOptions::new().name(socket.clone());
390                        match opts.create_sync() {
391                            Ok(listener) => {
392                                info!("Successfully claimed the address");
393                                Ok(listener)
394                            }
395                            Err(e) => {
396                                error!("Could not create a listener: {e}");
397                                Err(Error::SocketError)
398                            }
399                        }
400                    }
401                    AddrClaimMode::ForceClaim => {
402                        info!("Force claiming the socket address");
403                        if let Err(e) = remove_file(get_fs_socket_path(socket_name)) {
404                            error!("Could not remove the old socket: {e}");
405                            return Err(Error::SocketError);
406                        }
407                        let opts = ListenerOptions::new().name(socket.clone());
408                        match opts.create_sync() {
409                            Ok(listener) => {
410                                info!("Successfully claimed the address");
411                                Ok(listener)
412                            }
413                            Err(e) => {
414                                error!(
415                                    "Could not create a listener despite claiming the socket: {e}"
416                                );
417                                Err(Error::SocketError)
418                            }
419                        }
420                    }
421                }
422            } else {
423                error!("Failed creating a listener for the daemon socket: {e}");
424                Err(Error::SocketError)
425            }
426        }
427    }
428}
429
430/// Sends a command from a daemon thread to the main daemon process.
431pub(crate) fn send_command(command: ThreadCommand) -> Result<(), String> {
432    let conf_guard = crate::CONFIG.read().unwrap();
433    let config = match conf_guard.as_ref() {
434        Some(conf) => conf,
435        None => return Err(Error::DaemonNotRunning.to_string()),
436    };
437    let sender_guard = COMMAND_SENDER.read().unwrap();
438    let sender = match sender_guard.as_ref() {
439        Some(sender) => sender,
440        None => return Err(Error::DaemonNotRunning.to_string()),
441    };
442    if command == ThreadCommand::Quit {
443        if !config.can_quit {
444            return Err(Error::QuitDisabled.to_string());
445        }
446    } else if command == ThreadCommand::Raise && !config.can_raise {
447        return Err(Error::RaiseDisabled.to_string());
448    }
449    if let Err(e) = sender.send(command) {
450        error!("Could not send the thread command to the daemon: {e}");
451        return Err(e.to_string());
452    }
453    Ok(())
454}
455
456/// Send an event to the daemon thread.
457pub(crate) fn send_event(event: Event) {
458    let mut senders = EVENT_SENDERS.write().unwrap();
459    let mut dead = Vec::new();
460    for (i, sender) in senders.iter().enumerate() {
461        if sender.send(event.clone()).is_err() {
462            info!("Removing dead event sender at {i}");
463            dead.push(i);
464        }
465    }
466    for ded in dead {
467        senders.swap_remove(ded);
468    }
469}
470
471/// Subscribe to important state changes.
472pub(crate) fn subscribe_to_events() -> Result<mpsc::Receiver<Event>, chilen_ipc::Error> {
473    let mut events = Vec::new();
474    match get_library() {
475        Ok(lib) => {
476            events.push(Event::LibraryChanged(lib.into()));
477        }
478        Err(e) => {
479            error!("Could not get the contents of the music library: {e}");
480            return Err(e);
481        }
482    }
483    match playback::get_initial_events() {
484        Ok(playback_events) => {
485            for event in playback_events {
486                events.push(event);
487            }
488        }
489        Err(e) => {
490            error!("Could not get the initial events from the playback module: {e}");
491            return Err(e);
492        }
493    };
494    let guard = crate::CONFIG.read().unwrap();
495    let conf = guard.as_ref().unwrap();
496    events.push(Event::CanRaiseChanged(conf.can_raise));
497    events.push(Event::CanQuitChanged(conf.can_quit));
498    let (sender, receiver) = mpsc::channel();
499    for event in events {
500        sender.send(event).unwrap();
501    }
502    let mut guard = EVENT_SENDERS.write().unwrap();
503    let senders: &mut Vec<mpsc::Sender<Event>> = guard.as_mut();
504    senders.push(sender);
505    Ok(receiver)
506}
507
508pub(crate) fn raise() -> Result<(), Error> {
509    let guard = crate::CONFIG.read().unwrap();
510    if guard.as_ref().unwrap().can_raise {
511        match crate::send_command(ThreadCommand::Raise) {
512            Ok(_) => Ok(()),
513            Err(_) => Err(Error::RaiseDisabled),
514        }
515    } else {
516        Err(Error::RaiseDisabled)
517    }
518}
519
520pub(crate) fn set_fullscreen(fullscreen: bool) -> Result<(), Error> {
521    let guard = crate::CONFIG.read().unwrap();
522    if guard.as_ref().unwrap().can_set_fullscreen {
523        match crate::send_command(ThreadCommand::SetFullscreen(fullscreen)) {
524            Ok(_) => Ok(()),
525            Err(_) => Err(Error::SetFullscreenDisabled),
526        }
527    } else {
528        Err(Error::SetFullscreenDisabled)
529    }
530}
531
532/// Set whether clients can send raise requests to the daemon.
533///
534/// Will fail with [`Error::DaemonNotRunning`] if the daemon isn't running.
535pub fn set_can_raise(can_raise: bool) -> Result<(), Error> {
536    let mut conf_guard = CONFIG.write().unwrap();
537    let conf = match conf_guard.as_mut() {
538        Some(conf) => conf,
539        None => return Err(Error::DaemonNotRunning),
540    };
541    if conf.can_raise != can_raise {
542        conf.can_raise = can_raise;
543        send_event(Event::CanRaiseChanged(conf.can_raise));
544    }
545    Ok(())
546}
547
548pub fn set_can_set_fullscreen(can_set_fullscreen: bool) -> Result<(), Error> {
549    let mut conf_guard = CONFIG.write().unwrap();
550    let conf = match conf_guard.as_mut() {
551        Some(conf) => conf,
552        None => return Err(Error::DaemonNotRunning),
553    };
554    if conf.can_set_fullscreen != can_set_fullscreen {
555        conf.can_set_fullscreen = can_set_fullscreen;
556        send_event(Event::CanGoFullscreenChanged(conf.can_raise));
557    }
558    Ok(())
559}
560
561/// Set whether the daemon should accept quit requests from clients.
562///
563/// This does not affect the [`quit`] function.
564///
565/// Will fail with [`Error::DaemonNotRunning`] if the daemon isn't running.
566pub fn set_can_quit(can_quit: bool) -> Result<(), Error> {
567    let mut conf_guard = CONFIG.write().unwrap();
568    let conf = match conf_guard.as_mut() {
569        Some(conf) => conf,
570        None => return Err(Error::DaemonNotRunning),
571    };
572    if conf.can_quit != can_quit {
573        conf.can_quit = can_quit;
574        send_event(Event::CanQuitChanged(conf.can_quit));
575    }
576    Ok(())
577}
578
579/// Stop a running daemon instance.
580///
581/// If a daemon is not running, [`Error::DaemonNotRunning`] will be returned.
582pub fn quit() -> Result<(), Error> {
583    if send_command(ThreadCommand::Quit).is_err() {
584        error!("The daemon doesn't seem to be running");
585        Err(Error::DaemonNotRunning)
586    } else {
587        Ok(())
588    }
589}
590
591/// Same as [`stop`] with an additional checks to ensure an external client cannot stop the daemon
592/// if the configuration disallows it.
593#[cfg(feature = "mpris")]
594pub(crate) fn client_quit() -> Result<(), Error> {
595    let guard = crate::CONFIG.read().unwrap();
596    if guard.as_ref().unwrap().can_quit {
597        quit().unwrap();
598        Ok(())
599    } else {
600        Err(Error::QuitDisabled)
601    }
602}
603
604/// Start the daemon with the given config.
605///
606/// The [`Receiver`](mpsc::Receiver) returned by this functions can be used to listen for requests
607/// that are not broadcast to all clients connected via the IPC socket. This is to ensure requests
608/// such as [`Request::Raise`] do not cause conflicts when there are multiple clients attempting to
609/// handle them all at once.
610///
611/// The daemon usually starts listening for commands around 100ms after this function is
612/// called, but some commands sent too early might fail if the music library isn't loaded yet, the
613/// playback module is not initialized, or if the MPRIS server isn't running (if the MPRIS feature
614/// is enabled).
615///
616/// The initialization of the music library is by far the most time-consuming process during
617/// startup, and the time it takes vastly depends on the read speeds of the hard drive on the host
618/// machine and the size of the music library.
619///
620/// **Note:** This function launches the daemon in a separate thread, it doesn't block.
621///
622/// # Examples
623/// ```no_run
624/// # use chilen_daemon;
625/// let config = chilen_daemon::Config::try_default().unwrap();
626/// let (_, handle) = chilen_daemon::start(config);
627/// match handle.join().unwrap() {
628///     Ok(_) => println!("Daemon exited"),
629///     Err(e) => {
630///         panic!("Daemon failed: {e}");
631///     }
632/// }
633/// ```
634pub fn start(config: Config) -> (mpsc::Receiver<Request>, JoinHandle<Result<(), Error>>) {
635    let (sender, receiver) = mpsc::channel();
636    (receiver, thread::spawn(|| start_blocking(sender, config)))
637}
638
639// TEST: Add tests to make sure daemon can start and stop properly
640fn start_blocking(request_sender: mpsc::Sender<Request>, config: Config) -> Result<(), Error> {
641    debug!("Starting daemon on \"{}\"", config.socket_name);
642
643    *CONFIG.write().unwrap() = Some(config.clone());
644
645    if let Err(e) = music_lib::set_dirs(config.clone()) {
646        error!("Could not set the paths: {e}");
647        return Err(e);
648    }
649
650    if config.socket_name == chilen_ipc::DEFAULT_SOCKET_NAME {
651        warn!(
652            "Using the default IPC socket name. Please use a unique name outside of just testing"
653        );
654    }
655
656    let listener = get_listener(
657        &config.socket_name,
658        &config.socket_type,
659        &config.addr_claim_mode,
660    )?;
661
662    thread::spawn(move || {
663        let _ = music_lib::state::load(LoadMode::Load);
664        playback::init(config.playback_config);
665    });
666
667    thread::spawn({
668        move || {
669            for (index, conn) in listener.incoming().filter_map(handle_error).enumerate() {
670                // Size for `u64` is 8 bytes, and for `usize` it's 4 bytes on 32-bit and 8
671                // bytes on 64-bit, so the conversion can be done safely if I understand this
672                // correctly :)
673                daemon_thread::spawn(conn, u64::try_from(index).unwrap());
674            }
675        }
676    });
677
678    let mut sender_guard = COMMAND_SENDER.write().unwrap();
679    if sender_guard.as_ref().is_some() {
680        error!("The command channel is already initialized!");
681        return Err(Error::EventChannelInitialized);
682    }
683    let (sender, receiver) = mpsc::channel();
684    *sender_guard = Some(sender);
685    drop(sender_guard);
686
687    loop {
688        let cmd = receiver.recv().unwrap();
689        match cmd {
690            ThreadCommand::Raise => {
691                trace!("Received a raise request");
692                let _ = request_sender.send(Request::Raise);
693            }
694            ThreadCommand::SetFullscreen(fullscreen) => {
695                trace!("Received request to set fullscreen to: {fullscreen}");
696                let _ = request_sender.send(Request::SetFullscreen(fullscreen));
697            }
698            ThreadCommand::Quit => {
699                trace!("Received quit command");
700                send_event(Event::Quit);
701                cleanup();
702                info!("Stopped.");
703                return Ok(());
704            }
705        }
706    }
707}