diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs index a0c6fdf..28f574a 100644 --- a/linux-rust/src/bluetooth/aacp.rs +++ b/linux-rust/src/bluetooth/aacp.rs @@ -9,18 +9,13 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json; use std::path::PathBuf; +use crate::utils::get_devices_path; const PSM: u16 = 0x1001; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const POLL_INTERVAL: Duration = Duration::from_millis(200); const HEADER_BYTES: [u8; 4] = [0x04, 0x00, 0x04, 0x00]; -fn get_devices_path() -> PathBuf { - let data_dir = std::env::var("XDG_DATA_HOME") - .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); - PathBuf::from(data_dir).join("librepods").join("devices.json") -} - pub mod opcodes { pub const SET_FEATURE_FLAGS: u8 = 0x4D; pub const REQUEST_NOTIFICATIONS: u8 = 0x0F; @@ -268,11 +263,18 @@ pub struct AirPodsInformation { pub version3: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum DeviceInformation { + AirPods(AirPodsInformation), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceData { + pub name: String, pub type_: DeviceType, pub le: LEData, - pub information: Option, + pub information: Option, } pub struct AACPManagerState { @@ -620,7 +622,8 @@ impl AACPManager { let mut state = self.state.lock().await; if let Some(mac) = state.airpods_mac { if let Some(device_data) = state.devices.get_mut(&mac.to_string()) { - device_data.information = Some(info.clone()); + device_data.name = info.name.clone(); + device_data.information = Some(DeviceInformation::AirPods(info.clone())); } } let json = serde_json::to_string(&state.devices).unwrap(); @@ -668,6 +671,7 @@ impl AACPManager { if let Some(mac) = state.airpods_mac { let mac_str = mac.to_string(); let device_data = state.devices.entry(mac_str.clone()).or_insert(DeviceData { + name: mac_str.clone(), type_: DeviceType::AirPods, le: LEData { irk: "".to_string(), enc_key: "".to_string() }, information: None, diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs index da6f40e..0331e2f 100644 --- a/linux-rust/src/bluetooth/le.rs +++ b/linux-rust/src/bluetooth/le.rs @@ -2,46 +2,20 @@ use std::cmp::PartialEq; use bluer::monitor::{Monitor, MonitorEvent, Pattern}; use bluer::{Address, Session}; use aes::Aes128; -use aes::cipher::{BlockEncrypt, KeyInit, BlockDecrypt}; +use aes::cipher::{KeyInit, BlockDecrypt}; use aes::cipher::generic_array::GenericArray; use std::collections::{HashMap, HashSet}; use log::{info, debug}; use serde_json; -use crate::bluetooth::aacp::ProximityKeyType; use futures::StreamExt; use hex; -use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use tokio::sync::Mutex; use crate::bluetooth::aacp::BatteryStatus; use crate::ui::tray::MyTray; -use crate::bluetooth::aacp::{DeviceData, LEData, DeviceType}; - -fn get_devices_path() -> PathBuf { - let data_dir = std::env::var("XDG_DATA_HOME") - .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); - PathBuf::from(data_dir).join("librepods").join("devices.json") -} - -fn get_preferences_path() -> PathBuf { - let config_dir = std::env::var("XDG_CONFIG_HOME") - .unwrap_or_else(|_| format!("{}/.config", std::env::var("HOME").unwrap_or_default())); - PathBuf::from(config_dir).join("librepods").join("preferences.json") -} - -fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { - let mut swapped_key = *key; - swapped_key.reverse(); - let mut swapped_data = *data; - swapped_data.reverse(); - let cipher = Aes128::new(&GenericArray::from(swapped_key)); - let mut block = GenericArray::from(swapped_data); - cipher.encrypt_block(&mut block); - let mut result: [u8; 16] = block.into(); - result.reverse(); - result -} +use crate::bluetooth::aacp::{DeviceData, DeviceType}; +use crate::utils::{get_devices_path, get_preferences_path, ah}; fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&GenericArray::from(*key)); @@ -50,15 +24,6 @@ fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { block.into() } -fn ah(k: &[u8; 16], r: &[u8; 3]) -> [u8; 3] { - let mut r_padded = [0u8; 16]; - r_padded[..3].copy_from_slice(r); - let encrypted = e(k, &r_padded); - let mut hash = [0u8; 3]; - hash.copy_from_slice(&encrypted[..3]); - hash -} - fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool { let rpa: Vec = addr.split(':') .map(|s| u8::from_str_radix(s, 16).unwrap()) diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs index 73097d6..2d2912f 100644 --- a/linux-rust/src/main.rs +++ b/linux-rust/src/main.rs @@ -2,6 +2,7 @@ mod bluetooth; mod airpods; mod media_controller; mod ui; +mod utils; use std::env; use log::info; diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs index 5145282..d1c0634 100644 --- a/linux-rust/src/ui/window.rs +++ b/linux-rust/src/ui/window.rs @@ -1,9 +1,14 @@ +use std::collections::HashMap; use iced::widget::button::Style; -use iced::widget::{button, column, container, pane_grid, text, Space}; -use iced::{daemon, window, Background, Element, Length, Subscription, Task, Theme}; +use iced::widget::{button, column, container, pane_grid, text, Space, combo_box, row, text_input}; +use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Subscription, Task, Theme}; use std::sync::Arc; -use log::debug; +use iced::border::Radius; +use iced::overlay::menu; +use log::{debug, error}; use tokio::sync::{mpsc::UnboundedReceiver, Mutex}; +use crate::bluetooth::aacp::{DeviceData, DeviceInformation, DeviceType}; +use crate::utils::{get_devices_path, get_app_settings_path, MyTheme}; pub fn start_ui(ui_rx: UnboundedReceiver<()>, start_minimized: bool) -> iced::Result { daemon(App::title, App::update, App::view) @@ -17,6 +22,8 @@ pub struct App { panes: pane_grid::State, selected_tab: Tab, ui_rx: Arc>>, + theme_state: combo_box::State, + selected_theme: MyTheme, } #[derive(Debug, Clone)] @@ -26,14 +33,13 @@ pub enum Message { Resized(pane_grid::ResizeEvent), SelectTab(Tab), OpenMainWindow, + ThemeSelected(MyTheme), + CopyToClipboard(String), } -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Tab { - Device1, - Device2, - Device3, - Device4, + Device(String), Settings, } @@ -43,6 +49,7 @@ pub enum Pane { Content, } + impl App { pub fn new(ui_rx: UnboundedReceiver<()>, start_minimized: bool) -> (Self, Task) { let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar); @@ -62,12 +69,45 @@ impl App { (Some(id), open.map(Message::WindowOpened)) }; + let app_settings_path = get_app_settings_path(); + let selected_theme = std::fs::read_to_string(&app_settings_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("theme").cloned()) + .and_then(|t| serde_json::from_value(t).ok()) + .unwrap_or(MyTheme::Dark); + ( Self { window, panes, - selected_tab: Tab::Device1, + selected_tab: Tab::Device("none".to_string()), ui_rx, + theme_state: combo_box::State::new(vec![ + MyTheme::Light, + MyTheme::Dark, + MyTheme::Dracula, + MyTheme::Nord, + MyTheme::SolarizedLight, + MyTheme::SolarizedDark, + MyTheme::GruvboxLight, + MyTheme::GruvboxDark, + MyTheme::CatppuccinLatte, + MyTheme::CatppuccinFrappe, + MyTheme::CatppuccinMacchiato, + MyTheme::CatppuccinMocha, + MyTheme::TokyoNight, + MyTheme::TokyoNightStorm, + MyTheme::TokyoNightLight, + MyTheme::KanagawaWave, + MyTheme::KanagawaDragon, + MyTheme::KanagawaLotus, + MyTheme::Moonfly, + MyTheme::Nightfly, + MyTheme::Oxocarbon, + MyTheme::Ferra, + ]), + selected_theme, }, Task::batch(vec![open_task, wait_task]), ) @@ -104,7 +144,6 @@ impl App { Message::OpenMainWindow => { if let Some(window_id) = self.window { Task::batch(vec![ - window::minimize(window_id, false), window::gain_focus(window_id), ]) } else { @@ -113,78 +152,369 @@ impl App { open_task.map(Message::WindowOpened) } } + Message::ThemeSelected(theme) => { + self.selected_theme = theme; + let app_settings_path = get_app_settings_path(); + let settings = serde_json::json!({"theme": self.selected_theme}); + debug!("Writing settings to {}: {}", app_settings_path.to_str().unwrap() , settings); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + Message::CopyToClipboard(data) => { + iced::clipboard::write(data) + } } } fn view(&self, _id: window::Id) -> Element<'_, Message> { + let devices_json = std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); let pane_grid = pane_grid::PaneGrid::new(&self.panes, |_pane_id, pane, _is_maximized| { match pane { Pane::Sidebar => { - let create_tab_button = |tab: Tab, label: &str| -> Element<'_, Message> { + let create_tab_button = |tab: Tab, label: &str, description: Option<&str>| -> Element<'_, Message> { let label = label.to_string(); + let description = description.map(|d| d.to_string()); let is_selected = self.selected_tab == tab; - let content = container(text(label).size(18)).padding(10); + let mut col = column![text(label).size(18)]; + if let Some(desc) = description { + col = col.push(text(desc).size(12)); + } + let content = container(col) + .padding(10); let style = move |theme: &Theme, _status| { if is_selected { - Style::default() - .with_background(Background::Color(theme.palette().primary)) + let mut style = Style::default() + .with_background(theme.palette().primary); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(20); + style } else { - let mut style = Style::default(); + let mut style = Style::default() + .with_background(theme.palette().primary.scale_alpha(0.1)); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.1); + style.border = border.rounded(10); style.text_color = theme.palette().text; style } }; button(content) .style(style) + .padding(5) .on_press(Message::SelectTab(tab)) .width(Length::Fill) .into() }; - let devices = column![ - create_tab_button(Tab::Device1, "Device 1"), - create_tab_button(Tab::Device2, "Device 2"), - create_tab_button(Tab::Device3, "Device 3"), - create_tab_button(Tab::Device4, "Device 4") - ] - .spacing(5); + let mut devices = column!().spacing(4); + let mut devices_vec: Vec<(String, DeviceData)> = devices_list.clone().into_iter().collect(); + devices_vec.sort_by(|a, b| a.1.name.cmp(&b.1.name)); + for (mac, device) in devices_vec { + let name = device.name.clone(); + let tab_button = create_tab_button( + Tab::Device(mac.clone()), + &name, + Some(&mac) + ); + devices = devices.push(tab_button); + } - let settings = create_tab_button(Tab::Settings, "Settings"); + let settings = create_tab_button(Tab::Settings, "Settings", None); let content = column![ devices, Space::with_height(Length::Fill), settings ] - .spacing(5); + .padding(12); pane_grid::Content::new(content) } + Pane::Content => { - let content_text = match self.selected_tab { - Tab::Device1 => "Content for Device 1", - Tab::Device2 => "Content for Device 2", - Tab::Device3 => "Content for Device 3", - Tab::Device4 => "Content for Device 4", - Tab::Settings => "Settings content", + let content = match &self.selected_tab { + Tab::Device(id) => { + if id == "none" { + container( + text("Select a device".to_string()).size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } else { + let mut information_col = column![]; + + let device_type = devices_list.get(id) + .map(|d| d.type_.clone()).unwrap(); + + if device_type == DeviceType::AirPods { + let device_information = devices_list.get(id) + .and_then(|d| d.information.clone()); + match device_information { + Some(DeviceInformation::AirPods(ref airpods_information)) => { + information_col = information_col + .push(text("Device Information").size(18).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + )) + .push(Space::with_height(Length::from(10))) + .push( + row![ + text("Model Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_information.model_number.clone()).size(16) + ] + ) + .push( + row![ + text("Manufacturer").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_information.manufacturer.clone()).size(16) + ] + ) + .push( + row![ + text("Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + button( + text( + airpods_information.serial_number.clone() + ) + .size(16) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + } + ) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_information.serial_number.clone())) + ] + ) + .push( + row![ + text("Left Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + button( + text( + airpods_information.left_serial_number.clone() + ) + .size(16) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + } + ) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_information.left_serial_number.clone())) + ] + ) + .push( + row![ + text("Right Serial Number").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + button( + text( + airpods_information.right_serial_number.clone() + ) + .size(16) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + } + ) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_information.right_serial_number.clone())) + ] + ) + .push( + row![ + text("Version 1").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_information.version1.clone()).size(16) + ] + ) + .push( + row![ + text("Version 2").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_information.version2.clone()).size(16) + ] + ) + .push( + row![ + text("Version 3").size(16).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + } + ), + Space::with_width(Length::Fill), + text(airpods_information.version3.clone()).size(16) + ] + ); + debug!("AirPods Information: {:?}", airpods_information); + } + _ => { + error!("Expected AirPodsInformation, got something else: {:?}", device_information); + }, + } + } + container( + column![ + container(information_col) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(20); + style + } + ) + .padding(20) + ] + ) + .padding(20) + .center_x(Length::Fill) + .height(Length::Fill) + } + } + Tab::Settings => { + container( + column![ + text("Settings").size(40), + Space::with_height(Length::from(20)), + row![ + text("Theme:") + .size(16), + Space::with_width(Length::from(10)), + combo_box( + &self.theme_state, + "Select theme", + Some(&self.selected_theme), + Message::ThemeSelected + ) + .input_style( + |theme: &Theme, _status| { + text_input::Style { + background: Background::Color(Color::TRANSPARENT), + border: Border { + width: 0.5, + color: theme.palette().text, + radius: Radius::from(10.0), + }, + icon: Default::default(), + placeholder: theme.palette().text.scale_alpha(0.5), + value: theme.palette().text, + selection: theme.palette().primary + } + } + ) + .menu_style( + |theme: &Theme| { + menu::Style { + background: Background::Color(Color::TRANSPARENT), + border: Border { + width: 0.5, + color: theme.palette().text, + radius: Radius::from(10.0) + }, + text_color: theme.palette().text, + selected_text_color: theme.palette().text, + selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)), + } + } + ) + .width(Length::Fill) + ] + .align_y(Center) + ] + ) + .padding(20) + .width(Length::Fill) + .height(Length::Fill) + }, }; - let content = container(text(content_text).size(40)) - .center_x(Length::Fill) - .center_y(Length::Fill); pane_grid::Content::new(content) } } }) - .width(Length::Fill) - .height(Length::Fill) - .on_resize(20, Message::Resized); + .width(Length::Fill) + .height(Length::Fill) + .on_resize(20, Message::Resized); container(pane_grid).into() } fn theme(&self, _id: window::Id) -> Theme { - Theme::Moonfly + self.selected_theme.into() } fn subscription(&self) -> Subscription { diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs new file mode 100644 index 0000000..e561e78 --- /dev/null +++ b/linux-rust/src/utils.rs @@ -0,0 +1,130 @@ +use aes::cipher::generic_array::GenericArray; +use aes::cipher::{BlockEncrypt, KeyInit}; +use aes::Aes128; +use iced::Theme; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +pub fn get_devices_path() -> PathBuf { + let data_dir = std::env::var("XDG_DATA_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(data_dir).join("librepods").join("devices.json") +} + +pub fn get_preferences_path() -> PathBuf { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(config_dir).join("librepods").join("preferences.json") +} + +pub fn get_app_settings_path() -> PathBuf { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(config_dir).join("librepods").join("app_settings.json") +} + +fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { + let mut swapped_key = *key; + swapped_key.reverse(); + let mut swapped_data = *data; + swapped_data.reverse(); + let cipher = Aes128::new(&GenericArray::from(swapped_key)); + let mut block = GenericArray::from(swapped_data); + cipher.encrypt_block(&mut block); + let mut result: [u8; 16] = block.into(); + result.reverse(); + result +} + +pub fn ah(k: &[u8; 16], r: &[u8; 3]) -> [u8; 3] { + let mut r_padded = [0u8; 16]; + r_padded[..3].copy_from_slice(r); + let encrypted = e(k, &r_padded); + let mut hash = [0u8; 3]; + hash.copy_from_slice(&encrypted[..3]); + hash +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum MyTheme { + Light, + Dark, + Dracula, + Nord, + SolarizedLight, + SolarizedDark, + GruvboxLight, + GruvboxDark, + CatppuccinLatte, + CatppuccinFrappe, + CatppuccinMacchiato, + CatppuccinMocha, + TokyoNight, + TokyoNightStorm, + TokyoNightLight, + KanagawaWave, + KanagawaDragon, + KanagawaLotus, + Moonfly, + Nightfly, + Oxocarbon, + Ferra, +} + +impl std::fmt::Display for MyTheme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Light => "Light", + Self::Dark => "Dark", + Self::Dracula => "Dracula", + Self::Nord => "Nord", + Self::SolarizedLight => "Solarized Light", + Self::SolarizedDark => "Solarized Dark", + Self::GruvboxLight => "Gruvbox Light", + Self::GruvboxDark => "Gruvbox Dark", + Self::CatppuccinLatte => "Catppuccin Latte", + Self::CatppuccinFrappe => "Catppuccin Frappé", + Self::CatppuccinMacchiato => "Catppuccin Macchiato", + Self::CatppuccinMocha => "Catppuccin Mocha", + Self::TokyoNight => "Tokyo Night", + Self::TokyoNightStorm => "Tokyo Night Storm", + Self::TokyoNightLight => "Tokyo Night Light", + Self::KanagawaWave => "Kanagawa Wave", + Self::KanagawaDragon => "Kanagawa Dragon", + Self::KanagawaLotus => "Kanagawa Lotus", + Self::Moonfly => "Moonfly", + Self::Nightfly => "Nightfly", + Self::Oxocarbon => "Oxocarbon", + Self::Ferra => "Ferra", + }) + } +} + +impl From for Theme { + fn from(my_theme: MyTheme) -> Self { + match my_theme { + MyTheme::Light => Theme::Light, + MyTheme::Dark => Theme::Dark, + MyTheme::Dracula => Theme::Dracula, + MyTheme::Nord => Theme::Nord, + MyTheme::SolarizedLight => Theme::SolarizedLight, + MyTheme::SolarizedDark => Theme::SolarizedDark, + MyTheme::GruvboxLight => Theme::GruvboxLight, + MyTheme::GruvboxDark => Theme::GruvboxDark, + MyTheme::CatppuccinLatte => Theme::CatppuccinLatte, + MyTheme::CatppuccinFrappe => Theme::CatppuccinFrappe, + MyTheme::CatppuccinMacchiato => Theme::CatppuccinMacchiato, + MyTheme::CatppuccinMocha => Theme::CatppuccinMocha, + MyTheme::TokyoNight => Theme::TokyoNight, + MyTheme::TokyoNightStorm => Theme::TokyoNightStorm, + MyTheme::TokyoNightLight => Theme::TokyoNightLight, + MyTheme::KanagawaWave => Theme::KanagawaWave, + MyTheme::KanagawaDragon => Theme::KanagawaDragon, + MyTheme::KanagawaLotus => Theme::KanagawaLotus, + MyTheme::Moonfly => Theme::Moonfly, + MyTheme::Nightfly => Theme::Nightfly, + MyTheme::Oxocarbon => Theme::Oxocarbon, + MyTheme::Ferra => Theme::Ferra, + } + } +} \ No newline at end of file