Files
OneCAD-Draft/src/ui/window/plugin_manager.rs
T
Sebastian ed3e064a47 feat(plugins): include rustc version in plugin compatibility gate
OpenCADStudio/plugins#901

- Record the host's rustc --version output in the embedded version_info.json.
- Add rustc_version/rustc_declared/rustc_compatible to plugin manifests and
  release metadata, mirroring the existing acadrust gate.
- Reject API v4+ plugins whose declared rustc differs from the host, with a
  clear one-line error message.
- Propagate the rustc gate through the marketplace, install flow, and UI.
- Update plugin architecture docs to describe the new ABI metadata.
2026-08-29 10:34:47 +02:00

1078 lines
35 KiB
Rust

//! Plugin Manager window — lists the add-ons compiled into this build and lets
//! the user enable/disable each one. A disabled plugin keeps its manifest
//! listed but drops its ribbon tab and command dispatch (persisted across
//! launches). Dynamic loading still comes with the phase-2 loader; see
//! `docs/plugin-architecture.md`.
#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
use crate::app::Message;
use crate::plugin::external::{ExternalPlugin, RegistryEntry, ReleaseInfo};
use iced::widget::{
button, column, container, markdown, row, rule, scrollable, text, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::t;
use rustc_hash::{FxHashMap, FxHashSet};
use std::borrow::Cow;
/// Empty lane kept at the right edge of scroll content so the vertical
/// scrollbar never covers card controls.
const SCROLLBAR_GUTTER: f32 = 16.0;
/// Marketplace state passed to the Plugin Manager view.
pub struct MarketView<'a> {
pub registry: &'a [RegistryEntry],
pub registry_loading: bool,
pub registry_error: Option<&'a str>,
pub registry_error_details_open: bool,
pub input: &'a str,
pub search: &'a str,
pub repos: &'a [String],
pub release_tags: &'a FxHashMap<String, Vec<ReleaseInfo>>,
pub selected_tag: &'a FxHashMap<String, String>,
pub selected_repo: Option<&'a str>,
pub readmes: &'a FxHashMap<String, Result<markdown::Content, String>>,
pub readme_loading: &'a FxHashSet<String>,
pub status: &'a str,
}
/// Latest desktop builds, used by the browser-only plugin notice.
#[cfg(target_arch = "wasm32")]
pub const DESKTOP_DOWNLOAD_URL: &str =
"https://github.com/HakanSeven12/OpenCADStudio/releases/latest";
// Register the command names for autocomplete. On the web they open a desktop
// download notice instead of the native marketplace.
inventory::submit!(crate::command::CommandRegistration {
names: &["PLUGINS", "PLUGINMANAGER"]
});
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
fn primary_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
}
}
fn badge<'a>(label: String) -> Element<'a, Message> {
container(text(label).size(11))
.padding([2, 8])
.style(|theme: &Theme| {
let pair = theme.palette().primary.weak;
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
})
.into()
}
fn toggle_button<'a>(id: &str, disabled: bool) -> Element<'a, Message> {
// Label shows the action the click performs.
let label = if disabled {
t!("Enable")
} else {
t!("Disable")
};
let want_enabled = disabled; // clicking flips the state
let id_owned = id.to_string();
button(text(label).size(12))
.padding([3, 12])
.on_press(Message::SetPluginEnabled(id_owned, want_enabled))
.style(if disabled { button::success } else { button::danger })
.into()
}
#[derive(Clone, Copy)]
enum StatusKind {
Muted,
Success,
Danger,
Warning,
}
/// Coloured status pill for a discovered external package.
fn status_badge<'a>(label: Cow<'static, str>, kind: StatusKind) -> Element<'a, Message> {
container(text(label).size(11))
.padding([2, 8])
.style(move |theme: &Theme| {
let palette = theme.palette();
let pair = match kind {
StatusKind::Muted => palette.background.weak,
StatusKind::Success => palette.success.weak,
StatusKind::Danger => palette.danger.weak,
StatusKind::Warning => palette.warning.weak,
};
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
})
.into()
}
fn card_style(theme: &Theme, selected: bool) -> container::Style {
let palette = theme.palette();
container::Style {
background: selected
.then(|| Background::Color(palette.primary.weak.color.scale_alpha(0.18))),
border: Border {
color: if selected {
palette.primary.base.color
} else {
palette.background.strong.color
},
width: if selected { 1.5 } else { 1.0 },
radius: 6.0.into(),
},
..Default::default()
}
}
fn repository_for_external(
plugin: &ExternalPlugin,
registry: &[RegistryEntry],
) -> Option<String> {
plugin.repository.clone().or_else(|| {
registry
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name))
.map(|entry| entry.repo.clone())
})
}
fn matches_search(query: &str, values: &[&str]) -> bool {
let query = query.trim().to_lowercase();
query.is_empty()
|| values
.iter()
.any(|value| value.to_lowercase().contains(&query))
}
fn external_matches_search(plugin: &ExternalPlugin, repository: Option<&str>, query: &str) -> bool {
matches_search(
query,
&[
&plugin.name,
&plugin.id,
&plugin.description,
repository.unwrap_or_default(),
],
) || plugin
.command_prefixes
.iter()
.any(|command| matches_search(query, &[command]))
}
fn repository_is_installed(
repository: &str,
registry_name: Option<&str>,
externals: &[ExternalPlugin],
) -> bool {
externals.iter().any(|plugin| {
plugin.repository.as_deref() == Some(repository)
|| registry_name
.is_some_and(|name| plugin.name.eq_ignore_ascii_case(name))
})
}
fn trim_version_prefix(value: &str) -> &str {
value.trim_start_matches(|c| c == 'v' || c == 'V')
}
fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option<String> {
let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?;
releases
.iter()
.filter(|release| {
ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
&& release.acadrust_compatible
&& release.rustc_compatible
})
.filter_map(|release| {
semver::Version::parse(trim_version_prefix(&release.tag))
.ok()
.map(|version| (version, release.tag.clone()))
})
.filter(|(version, _)| version > &installed)
.max_by(|(left, _), (right, _)| left.cmp(right))
.map(|(_, tag)| tag)
}
fn external_card<'a>(
p: &ExternalPlugin,
repository: Option<String>,
update_tag: Option<String>,
loaded: bool,
load_error: Option<&str>,
disabled: bool,
selected: bool,
) -> Element<'a, Message> {
let failed_old_api = load_error.is_some() && !p.api_compatible();
let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible();
let rustc_mismatch = p.rustc_declared && !p.rustc_compatible();
let (status, kind) = if loaded && disabled {
(t!("Disabled"), StatusKind::Muted)
} else if loaded {
(t!("Loaded"), StatusKind::Success)
} else if !p.api_compatible() || failed_old_api {
(t!("API incompatible"), StatusKind::Danger)
} else if acadrust_mismatch || rustc_mismatch {
(t!("Incompatible"), StatusKind::Danger)
} else if load_error.is_some() {
(t!("Load failed"), StatusKind::Danger)
} else if !p.lib_present {
(t!("No library"), StatusKind::Warning)
} else {
(t!("Restart to load"), StatusKind::Warning)
};
let header = row![
text(p.name.clone()).size(15),
Space::new().width(8),
badge(format!("v{}", p.version)),
Space::new().width(8),
badge(format!("API {}", p.api_version)),
Space::new().width(Fill),
status_badge(status, kind),
]
.align_y(iced::Center);
let id_line = text(p.id.clone()).size(11).style(muted_style);
let mut info_body = column![header, id_line].spacing(5);
if !p.description.is_empty() {
info_body = info_body.push(text(p.description.clone()).size(12).style(muted_style));
}
if !p.command_prefixes.is_empty() {
info_body = info_body.push(
text(t!(
"Commands: %{cmds}",
cmds = p.command_prefixes.join(", ")
))
.size(11)
.style(muted_style),
);
}
if let Some(error) = load_error {
let detail = if failed_old_api {
t!(
"Plugin API %{plugin} is incompatible with this build's API %{build} ribbon ABI. Update or rebuild the plugin.",
plugin = p.api_version,
build = ocs_plugin_api::API_VERSION,
)
} else {
t!("Load failed: %{error}", error = error)
};
info_body = info_body.push(
text(detail)
.size(11)
.width(Fill)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
}),
);
}
let info: Element<'a, Message> = if let Some(repo) = repository.clone() {
button(info_body.width(Fill))
.width(Fill)
.padding(0)
.style(button::text)
.on_press(Message::PluginReadmeSelect(repo))
.into()
} else {
info_body.width(Fill).into()
};
let mut actions = row![Space::new().width(Fill)].align_y(iced::Center);
if let (Some(repo), Some(tag)) = (repository, update_tag) {
actions = actions.push(pill_button(
t!("Update to %{tag}", tag = tag),
Message::PluginUpdate(repo, tag),
button::primary,
));
actions = actions.push(Space::new().width(6));
}
// A loaded plugin can be turned off (drops its ribbon tab + dispatch).
if loaded {
actions = actions.push(toggle_button(&p.id, disabled));
actions = actions.push(Space::new().width(6));
}
actions = actions.push(pill_button(
t!("Uninstall"),
Message::PluginUninstall(p.id.clone()),
button::danger,
));
container(column![info, actions].spacing(8).padding([10, 12]))
.width(Fill)
.style(move |theme: &Theme| card_style(theme, selected))
.into()
}
fn pill_button<'a>(
label: impl Into<Cow<'static, str>>,
msg: Message,
style: fn(&Theme, button::Status) -> button::Style,
) -> Element<'a, Message> {
button(text(label.into()).size(12))
.padding([4, 12])
.on_press(msg)
.style(style)
.into()
}
/// Square icon variant of [`pill_button`] for glyph-free actions (e.g. remove).
fn pill_icon_button<'a>(
icon: &'static [u8],
msg: Message,
style: fn(&Theme, button::Status) -> button::Style,
) -> Element<'a, Message> {
button(crate::ui::icons::themed(icon, 11.0))
.padding([5, 9])
.on_press(msg)
.style(style)
.into()
}
/// Release dropdown + Install (+ optional unlink) for one repo.
fn install_controls<'a>(
repo: &str,
releases: Vec<ReleaseInfo>,
selected: Option<String>,
removable: bool,
) -> Element<'a, Message> {
let repo_s = repo.to_string();
let tags = releases
.iter()
.map(|release| release.tag.clone())
.collect::<Vec<_>>();
let selected_release = selected.as_ref().and_then(|selected| {
releases.iter().find(|release| release.tag == *selected)
});
let picker: Element<'_, Message> = if tags.is_empty() {
text(t!("no releases")).size(11).style(muted_style).into()
} else {
let r = repo_s.clone();
iced::widget::pick_list(selected, tags, |value| value.to_string())
.on_select(move |tag| Message::PluginReleaseSelect(r.clone(), tag))
.text_size(12)
.into()
};
let action = match selected_release {
Some(release)
if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version)
&& release.acadrust_compatible
&& release.rustc_compatible =>
{
pill_button(
t!("Install"),
Message::PluginInstall(repo_s.clone()),
button::success,
)
}
Some(_) => status_badge(t!("Incompatible"), StatusKind::Danger),
None => status_badge(t!("Unavailable"), StatusKind::Muted),
};
let mut controls = row![picker, Space::new().width(8), action]
.align_y(iced::Center)
.spacing(4);
if removable {
controls = controls.push(Space::new().width(6));
controls = controls.push(pill_icon_button(
crate::ui::icons::CLOSE,
Message::PluginRepoRemove(repo_s),
button::danger,
));
}
controls.into()
}
fn market_card<'a>(
body: iced::widget::Column<'a, Message>,
selected: bool,
) -> Element<'a, Message> {
container(body.spacing(4).padding([10, 12]))
.width(Fill)
.style(move |theme: &Theme| card_style(theme, selected))
.into()
}
fn repository_display_name(repository: &str) -> String {
repository
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(repository)
.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
if part.eq_ignore_ascii_case("opencad") {
"OpenCAD".to_string()
} else {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn add_repository_card<'a>(m: &MarketView) -> Element<'a, Message> {
let form = row![
text_input(t!("GitHub URL or owner/repository").as_ref(), m.input)
.on_input(Message::PluginRepoInput)
.on_submit(Message::PluginRepoAdd)
.size(13)
.width(Fill),
Space::new().width(8),
pill_button(
t!("Add repository"),
Message::PluginRepoAdd,
button::primary,
),
]
.align_y(iced::Center);
market_card(
column![
text(t!("Add from GitHub")).size(14),
text(t!(
"Paste a public repository URL. Compatible releases and the README are detected automatically."
))
.size(11)
.style(muted_style),
Space::new().height(3),
form,
]
.spacing(5),
false,
)
}
fn registry_error_message(error: &str) -> (Cow<'static, str>, Cow<'static, str>) {
let error = error.to_ascii_lowercase();
if error.contains("certificate")
|| error.contains("unknownissuer")
|| error.contains("unknown issuer")
{
(
t!("Unable to verify the server certificate"),
t!(
"Open CAD Studio could not trust the certificate presented for the plugin registry. Check your system certificate and proxy settings, then retry."
),
)
} else if error.contains("timed out") || error.contains("timeout") {
(
t!("Plugin registry request timed out"),
t!(
"Check your internet or proxy connection, then retry. Manually added repositories remain available."
),
)
} else {
(
t!("Unable to load the plugin registry"),
t!(
"Check your internet or proxy connection, then retry. Manually added repositories remain available."
),
)
}
}
fn registry_notice<'a>(m: &MarketView) -> Option<Element<'a, Message>> {
if let Some(error) = m.registry_error {
let (title, message) = registry_error_message(error);
let actions = row![
pill_button(
t!("Retry"),
Message::PluginRegistryRetry,
button::primary,
),
Space::new().width(6),
pill_button(
if m.registry_error_details_open {
t!("Hide details")
} else {
t!("Show details")
},
Message::PluginRegistryErrorDetailsToggle,
button::secondary,
),
Space::new().width(6),
pill_button(
t!("Copy details"),
Message::PluginRegistryCopyDiagnostics,
button::secondary,
),
]
.align_y(iced::Center);
let mut body = column![
text(title).size(13),
text(message).size(11).style(muted_style),
Space::new().height(3),
actions,
]
.spacing(5);
if m.registry_error_details_open {
body = body.push(
container(text(error.to_string()).size(10).width(Fill))
.padding(8)
.width(Fill)
.style(container::bordered_box),
);
}
return Some(
container(body.padding([10, 12]))
.width(Fill)
.style(|theme: &Theme| {
let pair = theme.palette().warning.weak;
container::Style {
background: Some(Background::Color(pair.color.scale_alpha(0.16))),
border: Border {
color: pair.color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
}
})
.into(),
);
}
(m.registry_loading && m.registry.is_empty()).then(|| {
container(
column![
text(t!("Loading plugin catalog…")).size(13),
text(t!("Connecting securely using your system certificate settings."))
.size(11)
.style(muted_style),
]
.spacing(5)
.padding([10, 12]),
)
.width(Fill)
.style(container::bordered_box)
.into()
})
}
fn marketplace_section<'a>(
m: &MarketView,
externals: &[ExternalPlugin],
) -> Element<'a, Message> {
let mut col = column![text(t!("Available plugins")).size(13).style(primary_style)].spacing(6);
let mut visible = 0usize;
if let Some(notice) = registry_notice(m) {
col = col.push(notice);
}
// Curated registry entries (from the OpenCADStudio repo).
for e in m.registry {
if repository_is_installed(&e.repo, Some(&e.name), externals)
|| !matches_search(m.search, &[&e.name, &e.description, &e.repo])
{
continue;
}
visible += 1;
let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default();
let selected = m.selected_tag.get(&e.repo).cloned();
let mut info = column![text(e.name.clone()).size(14)].spacing(4);
if !e.description.is_empty() {
info = info.push(text(e.description.clone()).size(12).style(muted_style));
}
let info = button(info.width(Fill))
.width(Fill)
.padding(0)
.style(button::text)
.on_press(Message::PluginReadmeSelect(e.repo.clone()));
let actions = row![
Space::new().width(Fill),
install_controls(&e.repo, tags, selected, false),
]
.align_y(iced::Center);
col = col.push(market_card(
column![info, actions].spacing(8),
m.selected_repo == Some(e.repo.as_str()),
));
}
// User-linked repositories join the same catalog, but curated and installed
// repositories are suppressed so every plugin appears only once.
for repo in m.repos {
if m.registry.iter().any(|entry| entry.repo == *repo)
|| repository_is_installed(repo, None, externals)
{
continue;
}
let display_name = repository_display_name(repo);
if !matches_search(m.search, &[&display_name, repo]) {
continue;
}
visible += 1;
let tags = m.release_tags.get(repo).cloned().unwrap_or_default();
let selected = m.selected_tag.get(repo).cloned();
let info = button(
column![
text(display_name).size(14),
text(t!("Added from GitHub")).size(11).style(muted_style),
]
.spacing(4)
.width(Fill),
)
.width(Fill)
.padding(0)
.style(button::text)
.on_press(Message::PluginReadmeSelect(repo.clone()));
let actions = row![
Space::new().width(Fill),
install_controls(repo, tags, selected, true),
]
.align_y(iced::Center);
col = col.push(market_card(
column![info, actions].spacing(8),
m.selected_repo == Some(repo.as_str()),
));
}
if visible == 0
&& m.registry_error.is_none()
&& !(m.registry_loading && m.registry.is_empty())
{
let message = if m.search.trim().is_empty() {
t!("No additional plugins are available.")
} else {
t!("No available plugins match your search.")
};
col = col.push(text(message).size(12).style(muted_style));
}
col = col.push(Space::new().height(8));
col = col.push(add_repository_card(m));
if !m.status.is_empty() {
col = col.push(text(m.status.to_string()).size(11).style(muted_style));
}
col.into()
}
fn resolve_readme_link(repo: &str, uri: &str) -> String {
if uri.starts_with("https://")
|| uri.starts_with("http://")
|| uri.starts_with("mailto:")
{
uri.to_string()
} else if uri.starts_with('/') {
format!("https://github.com{uri}")
} else if uri.starts_with('#') {
format!("https://github.com/{repo}{uri}")
} else {
format!(
"https://github.com/{repo}/blob/HEAD/{}",
uri.trim_start_matches("./")
)
}
}
fn readme_panel<'a>(
market: &MarketView<'a>,
theme: &Theme,
width: Length,
height: Length,
) -> Element<'a, Message> {
let Some(repo) = market.selected_repo else {
return container(
column![
text(t!("Plugin details")).size(16),
text(t!("Select a plugin to read its GitHub README."))
.size(12)
.style(muted_style),
]
.spacing(8),
)
.center_x(width)
.center_y(height)
.width(width)
.height(height)
.style(container::bordered_box)
.into();
};
let name = repo
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(repo)
.to_string();
let header = row![
column![
text(name).size(16),
text(repo.to_string()).size(11).style(primary_style),
]
.spacing(3)
.width(width),
pill_button(
t!("View on GitHub"),
Message::OpenUrl(format!("https://github.com/{repo}")),
button::secondary,
),
]
.align_y(iced::Center);
let content: Element<'a, Message> = if market.readme_loading.contains(repo) {
container(
column![
text(t!("Loading README…")).size(13),
text(t!("Fetching the default branch from GitHub."))
.size(11)
.style(muted_style),
]
.spacing(6),
)
.center_x(width)
.center_y(height)
.width(width)
.height(height)
.into()
} else {
match market.readmes.get(repo) {
Some(Ok(readme)) => {
let source_repo = repo.to_string();
markdown::view(
readme.items(),
markdown::Settings::with_text_size(13, theme),
)
.map(move |uri| {
Message::OpenUrl(resolve_readme_link(&source_repo, &uri))
})
}
Some(Err(error)) => container(
column![
text(t!("README could not be loaded")).size(14),
text(error.clone()).size(11).style(muted_style),
Space::new().height(4),
pill_button(
t!("Retry"),
Message::PluginReadmeSelect(repo.to_string()),
button::secondary,
),
]
.spacing(6),
)
.center_x(width)
.center_y(height)
.width(width)
.height(height)
.into(),
None => container(
text(t!("Select the plugin again to load its README."))
.size(12)
.style(muted_style),
)
.center_x(width)
.center_y(height)
.width(width)
.height(height)
.into(),
}
};
let gutter = iced::Padding {
top: 8.0,
right: SCROLLBAR_GUTTER,
bottom: 8.0,
left: 0.0,
};
let readme = scrollable(container(content).padding(gutter))
.height(height)
.width(width);
container(
column![header, rule::horizontal(1), readme]
.spacing(10)
.padding([12, 14])
.width(width)
.height(height),
)
.width(width)
.height(height)
.style(container::bordered_box)
.into()
}
pub fn view_window<'a>(
disabled: &FxHashSet<String>,
externals: &[ExternalPlugin],
loaded: &FxHashSet<String>,
load_errors: &FxHashMap<String, String>,
market: MarketView<'a>,
theme: &'a Theme,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let width = sizing.width;
let height = sizing.height;
let title = text(t!("Plugins")).size(20);
let subtitle =
text(t!("Browse, install, and manage add-ons. Select one to view its README."))
.size(12)
.style(muted_style);
let mut list = column![].spacing(10);
// Installed external packages (from the plugins folder).
if externals.is_empty() {
list = list.push(text(t!("No plugins installed yet.")).size(13).style(muted_style));
} else {
let mut visible_installed = 0usize;
for p in externals {
let repository = repository_for_external(p, market.registry);
if !external_matches_search(p, repository.as_deref(), market.search) {
continue;
}
if visible_installed == 0 {
list = list.push(text(t!("Installed")).size(13).style(primary_style));
}
visible_installed += 1;
let selected = repository.as_deref() == market.selected_repo;
let update_tag = repository
.as_ref()
.and_then(|repo| market.release_tags.get(repo))
.and_then(|releases| newest_update(&p.version, releases));
list = list.push(external_card(
p,
repository,
update_tag,
loaded.contains(&p.id),
load_errors.get(&p.id).map(String::as_str),
disabled.contains(&p.id),
selected,
));
}
if visible_installed == 0 && !market.search.trim().is_empty() {
list = list.push(
text(t!("No installed plugins match your search."))
.size(12)
.style(muted_style),
);
}
}
// Marketplace: install from a linked repository's releases.
list = list.push(Space::new().height(14));
list = list.push(marketplace_section(&market, externals));
let gutter = iced::Padding {
top: 0.0,
right: SCROLLBAR_GUTTER,
bottom: 0.0,
left: 0.0,
};
let catalog = scrollable(container(list.width(Fill)).padding(gutter))
.height(height)
.width(Fill);
let search = text_input(t!("Search plugins…").as_ref(), market.search)
.on_input(Message::PluginSearchInput)
.size(13)
.padding([7, 10])
.width(Fill);
let catalog_pane = column![search, catalog]
.spacing(10)
.width(Fill)
.height(height);
let details = readme_panel(&market, theme, width, height);
let body = row![
container(catalog_pane)
.width(Length::Fixed(410.0))
.height(height),
details,
]
.spacing(14)
.height(height)
.width(width);
container(
column![title, subtitle, Space::new().height(12), body]
.spacing(4)
.padding(18)
.width(width)
.height(height),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
..Default::default()
})
.width(width)
.height(height)
.into()
}
/// Browser replacement for the native Plugin Manager.
#[cfg(target_arch = "wasm32")]
pub fn view_web_notice<'a>() -> Element<'a, Message> {
let download = button(text(t!("Download desktop app")).size(13))
.on_press(Message::OpenUrl(DESKTOP_DOWNLOAD_URL.to_string()))
.padding([9, 18])
.style(|theme: &Theme, status| {
let mut style = button::primary(theme, status);
style.text_color = iced::Color::WHITE;
style
});
let icon = container(crate::ui::icons::themed_primary(
crate::ui::icons::GEAR,
26.0,
))
.center(Length::Fixed(44.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().primary.weak.color,
)),
border: Border {
radius: 12.0.into(),
..Default::default()
},
..Default::default()
});
let notice = container(
column![
icon,
text(t!("Plugins are available in the desktop app"))
.size(20)
.width(Length::Fit)
.align_x(iced::alignment::Horizontal::Center),
text(t!(
"Open CAD Studio plugins are native packages and cannot run inside a browser. Download the desktop app to browse, install, and use plugins."
))
.size(13)
.width(Length::Fit)
.align_x(iced::alignment::Horizontal::Center)
.style(muted_style),
Space::new().height(4),
download,
]
.spacing(10)
.align_x(iced::alignment::Horizontal::Center)
.width(Length::Fit),
)
.width(Length::Fit.max(380.0));
container(notice)
.center_x(Length::Fit)
.padding([16, 20])
.width(Length::Fit)
.height(Length::Fit)
.into()
}
#[cfg(test)]
mod tests {
use super::{newest_update, registry_error_message, repository_display_name};
use crate::plugin::external::ReleaseInfo;
#[test]
fn newest_update_uses_semver_not_release_order() {
let releases = vec![
ReleaseInfo {
tag: "v1.4.0".to_string(),
api_version: 4,
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
ReleaseInfo {
tag: "v2.0.0".to_string(),
api_version: 4,
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
ReleaseInfo {
tag: "v1.9.9".to_string(),
api_version: 4,
acadrust_source: None,
acadrust_declared: false,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
];
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0"));
assert_eq!(newest_update("2.0.0", &releases), None);
}
#[test]
fn newest_update_ignores_incompatible_releases() {
let releases = vec![
ReleaseInfo {
tag: "v1.4.0".to_string(),
api_version: 4,
acadrust_source: Some(
"git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(),
),
acadrust_declared: true,
acadrust_compatible: true,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
ReleaseInfo {
tag: "v2.0.0".to_string(),
api_version: 4,
acadrust_source: Some(
"git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(),
),
acadrust_declared: true,
acadrust_compatible: false,
rustc_version: None,
rustc_declared: false,
rustc_compatible: true,
},
];
assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0"));
}
#[test]
fn repository_name_is_human_readable() {
assert_eq!(
repository_display_name("owner/opencad-storm_sewer-plugin"),
"OpenCAD Storm Sewer Plugin",
);
}
#[test]
fn certificate_errors_get_user_friendly_copy() {
// The registry message is localised; pin English so the assertion
// does not depend on the ambient process locale.
crate::i18n::set_language(crate::i18n::Language::EnUs)
.expect("English locale must be available");
let (title, message) =
registry_error_message("io: invalid peer certificate: UnknownIssuer");
assert_eq!(title, "Unable to verify the server certificate");
assert!(message.contains("system certificate"));
assert!(!message.contains("UnknownIssuer"));
}
}