use std::path::PathBuf;
use bevy::{
prelude::*,
tasks::{AsyncComputeTaskPool, Task, futures_lite::future},
};
use jackdaw_api::prelude::ExtensionKind;
use jackdaw_api_internal::{
extensions_config::persist_current_enabled,
lifecycle::{Extension, ExtensionCatalog},
paths::config_dir,
};
use jackdaw_feathers::{
button::{ButtonClickEvent, ButtonProps, ButtonSize, ButtonVariant, button},
checkbox::{CheckboxCommitEvent, CheckboxProps, checkbox},
dialog::{CloseDialogEvent, DialogChildrenSlot, OpenDialogEvent},
icons::{EditorFont, Icon, IconFont},
tokens,
};
use rfd::{AsyncFileDialog, FileHandle};
use crate::extension_resolution;
use jackdaw_api_internal::lifecycle::{disable_extension, enable_extension};
pub struct ExtensionsDialogPlugin;
impl Plugin for ExtensionsDialogPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ExtensionsDialogOpen>()
.init_resource::<InstallStatus>()
.add_systems(Update, populate_extensions_dialog)
.add_systems(Update, poll_install_task)
.add_observer(on_extension_checkbox_commit)
.add_observer(on_install_button_click)
.add_observer(on_dialog_closed);
}
}
fn on_dialog_closed(_: On<CloseDialogEvent>, mut open: ResMut<ExtensionsDialogOpen>) {
open.0 = false;
}
#[derive(Resource, Default)]
struct ExtensionsDialogOpen(bool);
#[derive(Component)]
struct ExtensionCheckbox {
extension_id: String,
}
#[derive(Component)]
struct InstallFromFileButton;
#[derive(Component)]
struct InstallStatusText;
#[derive(Component)]
struct ExtensionsDialogContent;
#[derive(Resource, Default)]
pub struct InstallStatus {
pub task: Option<Task<Option<FileHandle>>>,
pub message: Option<String>,
}
pub fn open_extensions_dialog(world: &mut World) {
world.resource_mut::<ExtensionsDialogOpen>().0 = true;
world.trigger(
OpenDialogEvent::new("Extensions", "Close")
.without_cancel()
.with_max_width(Val::Px(380.0)),
);
}
fn populate_extensions_dialog(
mut commands: Commands,
catalog: Res<ExtensionCatalog>,
open: Res<ExtensionsDialogOpen>,
slots: Query<Entity, With<DialogChildrenSlot>>,
loaded: Query<&Extension>,
editor_font: Res<EditorFont>,
icon_font: Res<IconFont>,
existing: Query<(), With<ExtensionCheckbox>>,
) {
if !open.0 {
return;
}
if !existing.is_empty() {
return;
}
let Some(slot_entity) = slots.iter().next() else {
return;
};
let font = editor_font.0.clone();
let ifont = icon_font.0.clone();
let enabled_names: std::collections::HashSet<String> =
loaded.iter().map(|e| e.id.clone()).collect();
let mut builtin_rows: Vec<(String, String, bool)> = Vec::new();
let mut custom_rows: Vec<(String, String, bool)> = Vec::new();
for (id, label, _description, kind) in catalog.iter_with_content() {
if extension_resolution::is_required(&id) {
continue;
}
let row = (
id.to_string(),
label.to_string(),
enabled_names.contains(&id),
);
match kind {
ExtensionKind::Builtin => builtin_rows.push(row),
ExtensionKind::Regular => custom_rows.push(row),
}
}
builtin_rows.sort_by(|a, b| a.0.cmp(&b.0));
custom_rows.sort_by(|a, b| a.0.cmp(&b.0));
let list = commands
.spawn((
ChildOf(slot_entity),
ExtensionsDialogContent,
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(tokens::SPACING_XS),
min_width: Val::Px(280.0),
..default()
},
))
.id();
spawn_section_header(&mut commands, list, "Built-in");
for (id, label, checked) in builtin_rows {
commands.spawn((
ChildOf(list),
ExtensionCheckbox {
extension_id: id.clone(),
},
checkbox(CheckboxProps::new(label).checked(checked), &font, &ifont),
));
}
spawn_section_header(&mut commands, list, "Regular");
if custom_rows.is_empty() {
commands.spawn((
ChildOf(list),
Node {
padding: UiRect::axes(Val::Px(tokens::SPACING_LG), Val::Px(tokens::SPACING_SM)),
..default()
},
children![(
Text::new("No regular extensions installed"),
TextFont {
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_SECONDARY),
)],
));
} else {
for (id, label, checked) in custom_rows {
commands.spawn((
ChildOf(list),
ExtensionCheckbox {
extension_id: id.clone(),
},
checkbox(CheckboxProps::new(label).checked(checked), &font, &ifont),
));
}
}
spawn_install_row(&mut commands, list);
}
fn spawn_install_row(commands: &mut Commands, list: Entity) {
let row = commands
.spawn((
ChildOf(list),
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::axes(Val::Px(tokens::SPACING_LG), Val::Px(tokens::SPACING_SM)),
row_gap: Val::Px(tokens::SPACING_XS),
..default()
},
))
.id();
commands.spawn((
ChildOf(row),
InstallFromFileButton,
button(
ButtonProps::new("Install prebuilt dylib…")
.with_variant(ButtonVariant::Default)
.with_size(ButtonSize::MD)
.with_left_icon(Icon::FilePlus),
),
jackdaw_feathers::tooltip::Tooltip::title("Install Extension").with_description(
"Pick a prebuilt extension dylib (.so / .dll / .dylib) and copy \
it into the user extensions directory. The extension loads on \
the next editor restart.",
),
));
commands.spawn((
ChildOf(row),
InstallStatusText,
Text::new(String::new()),
TextFont {
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_SECONDARY),
));
}
fn spawn_section_header(commands: &mut Commands, list: Entity, label: &str) {
let header = commands
.spawn((
ChildOf(list),
Node {
padding: UiRect::new(
Val::Px(tokens::SPACING_LG),
Val::Px(tokens::SPACING_LG),
Val::Px(tokens::SPACING_MD),
Val::Px(tokens::SPACING_XS),
),
width: Val::Percent(100.0),
border: UiRect::bottom(Val::Px(1.0)),
..default()
},
BorderColor::all(tokens::BORDER_SUBTLE),
))
.id();
commands.spawn((
ChildOf(header),
Text::new(label.to_string()),
TextFont {
font_size: tokens::FONT_SM,
..default()
},
TextColor(tokens::TEXT_SECONDARY),
));
}
fn on_extension_checkbox_commit(
event: On<CheckboxCommitEvent>,
checkboxes: Query<&ExtensionCheckbox>,
mut commands: Commands,
) {
let Ok(cb) = checkboxes.get(event.entity) else {
return;
};
let name = cb.extension_id.clone();
let checked = event.checked;
if !checked && extension_resolution::is_required(&name) {
warn!("Refusing to disable required extension `{name}`");
return;
}
commands.queue(move |world: &mut World| {
if checked {
enable_extension(world, &name);
} else {
disable_extension(world, &name);
}
persist_current_enabled(world);
});
}
fn on_install_button_click(
event: On<ButtonClickEvent>,
buttons: Query<(), With<InstallFromFileButton>>,
mut commands: Commands,
) {
if buttons.get(event.entity).is_err() {
return;
}
commands.queue(|world: &mut World| {
if world.resource::<InstallStatus>().task.is_some() {
return;
}
let dialog = AsyncFileDialog::new().add_filter(
"Extension dylib",
&["so", "dylib", "dll"],
);
let task = AsyncComputeTaskPool::get().spawn(async move { dialog.pick_file().await });
world.resource_mut::<InstallStatus>().task = Some(task);
world.resource_mut::<InstallStatus>().message = Some("Select a dylib file…".into());
});
}
fn poll_install_task(
mut status: ResMut<InstallStatus>,
mut texts: Query<&mut Text, With<InstallStatusText>>,
mut commands: Commands,
) {
let Some(task) = status.task.as_mut() else {
sync_status_text(&status.message, &mut texts);
return;
};
let Some(handle) = future::block_on(future::poll_once(task)) else {
sync_status_text(&status.message, &mut texts);
return;
};
status.task = None;
match handle {
Some(picked) => {
let src = picked.path().to_path_buf();
commands.queue(move |world: &mut World| {
if let Err(err) = world.run_system_cached_with(handle_install, src) {
error!("Failed to install extension: {err}");
}
});
}
None => {
status.message = None;
}
}
sync_status_text(&status.message, &mut texts);
}
fn sync_status_text(
message: &Option<String>,
texts: &mut Query<&mut Text, With<InstallStatusText>>,
) {
let desired = message.as_deref().unwrap_or("");
for mut text in texts.iter_mut() {
if text.0 != desired {
text.0 = desired.to_string();
}
}
}
pub fn handle_install_from_path(
world: &mut World,
src: std::path::PathBuf,
) -> Result<jackdaw_loader::LoadedKind, jackdaw_loader::LoadError> {
world
.run_system_cached_with(handle_install, src)
.map_err(BevyError::from)
.map_err(jackdaw_loader::LoadError::from)
.flatten()
}
fn handle_install(
In(src): In<PathBuf>,
world: &mut World,
extension_dialogs: &mut QueryState<Entity, With<ExtensionsDialogContent>>,
) -> Result<jackdaw_loader::LoadedKind, jackdaw_loader::LoadError> {
let target = classify_for_install(&src);
let dest = match install_picked_file(&src, target) {
Ok(d) => d,
Err(err) => {
warn!("Failed to install dylib: {err}");
world.resource_mut::<InstallStatus>().message = Some(format!("Install failed: {err}"));
return Err(jackdaw_loader::LoadError::InstallIo(err.to_string()));
}
};
info!("Installed dylib to {}", dest.display());
let result = jackdaw_loader::load_from_path(world, &dest);
let msg = match &result {
Ok(jackdaw_loader::LoadedKind::Extension(name)) => {
info!("Live-loaded extension `{name}` from {}", dest.display());
format!("Loaded extension `{name}`. BEI keybinds (if any) activate on next restart.")
}
Ok(jackdaw_loader::LoadedKind::Game(name)) => {
info!("Game `{name}` loaded from {}", dest.display());
format!("Loaded game `{name}`.")
}
Err(err) => {
warn!("Live-load failed for {}: {err}", dest.display());
if err.is_symbol_mismatch() {
"SDK mismatch detected; cleaning project cache…".to_string()
} else {
format!(
"Installed to {}, but live-load failed: {err}. Restart the editor to retry.",
dest.display()
)
}
}
};
world.resource_mut::<InstallStatus>().message = Some(msg);
let targets: Vec<Entity> = extension_dialogs.iter(world).collect();
for entity in targets {
if let Ok(ec) = world.get_entity_mut(entity) {
ec.despawn();
}
}
result
}
enum InstallTarget {
Extension,
Game,
}
fn install_picked_file(
src: &std::path::Path,
target: InstallTarget,
) -> std::io::Result<std::path::PathBuf> {
let Some(config) = config_dir() else {
return Err(std::io::Error::other(
"platform config directory is unavailable",
));
};
let subdir = match target {
InstallTarget::Extension => "extensions",
InstallTarget::Game => "games",
};
let dest_dir = config.join(subdir);
std::fs::create_dir_all(&dest_dir)?;
let file_name = src.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"picked path has no file name",
)
})?;
let file_name_str = file_name.to_string_lossy();
let (stem, ext_with_dot) = match file_name_str.rfind('.') {
Some(i) => (&file_name_str[..i], &file_name_str[i..]),
None => (file_name_str.as_ref(), ""),
};
let ts_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let unique_name = format!("{stem}-{ts_ms}{ext_with_dot}");
let dest = dest_dir.join(&unique_name);
let temp_name = format!(
"{}{}-{}",
jackdaw_loader::INSTALL_TEMPFILE_PREFIX,
std::process::id(),
unique_name
);
let temp = dest_dir.join(temp_name);
std::fs::copy(src, &temp)?;
if let Err(e) = std::fs::rename(&temp, &dest) {
let _ = std::fs::remove_file(&temp);
return Err(e);
}
cleanup_prior_installs(&dest_dir, stem, ext_with_dot, &dest);
Ok(dest)
}
fn cleanup_prior_installs(
dir: &std::path::Path,
stem: &str,
ext_with_dot: &str,
keep: &std::path::Path,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path == keep {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let is_legacy = name == format!("{stem}{ext_with_dot}");
let is_timestamped = name
.strip_prefix(&format!("{stem}-"))
.and_then(|rest| rest.strip_suffix(ext_with_dot))
.is_some_and(|middle| middle.bytes().all(|b| b.is_ascii_digit()));
if !is_legacy && !is_timestamped {
continue;
}
if let Err(e) = std::fs::remove_file(&path) {
warn!("Failed to clean up prior install {}: {e}", path.display());
}
}
}
fn classify_for_install(path: &std::path::Path) -> InstallTarget {
match jackdaw_loader::peek_kind(path) {
Ok(jackdaw_loader::LoadedKind::Game(_)) => InstallTarget::Game,
_ => InstallTarget::Extension,
}
}