Compare commits

..
Author SHA1 Message Date
Hanif Koh 5d8a81a14e Defer Plugin Dock Panes Until the Plater Is Shown
A plugin enabled at startup loads before the main frame exists, so a
dock panel it opens from on_load was dropped by the one-shot CallAfter
that found no plater. Opened a moment later, before the frame was laid
out, the pane was sized against the unsized frame and track_docked_size
kept that width. Poll until the plater is shown on screen, then build
the pane; release the reserved id instead when the app is closing.
2026-10-07 20:47:56 +08:00
3 changed files with 70 additions and 85 deletions
+27 -42
View File
@@ -11,7 +11,6 @@
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <iterator>
#include <cctype>
#include <cstdlib>
#include <future>
@@ -145,28 +144,29 @@ static bool is_fs_category(AuditEventCategory category)
// Path safety
// ---------------------------------------------------------------------------
// weakly_canonical resolves symlinks but does NOT require the path to exist: it canonicalizes
// the prefix that exists and appends the non-existing tail lexically. Falls back to an absolute,
// lexically normal path when even that fails.
static boost::filesystem::path canonical_or_normal(const boost::filesystem::path& path)
{
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(path, ec);
if (ec) {
canon = fs::absolute(path, ec).lexically_normal();
if (ec)
canon = path;
}
return canon;
}
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
{
namespace fs = boost::filesystem;
fs::path canon_candidate = canonical_or_normal(candidate);
fs::path canon_root = canonical_or_normal(allowed_root);
boost::system::error_code ec;
// Canonicalize both paths. weakly_canonical resolves symlinks but does
// NOT require the path to exist — it canonicalizes the prefix that exists
// and appends the non-existing tail lexically.
fs::path canon_candidate = fs::weakly_canonical(candidate, ec);
if (ec) {
// Fall back to lexically_normal + absolute
canon_candidate = fs::absolute(candidate, ec).lexically_normal();
if (ec)
canon_candidate = candidate;
}
fs::path canon_root = fs::weakly_canonical(allowed_root, ec);
if (ec) {
canon_root = fs::absolute(allowed_root, ec).lexically_normal();
if (ec)
canon_root = allowed_root;
}
// Component-wise comparison: the root must be a prefix of candidate,
// and the next component must not be ".." or missing.
@@ -354,34 +354,19 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c
{
namespace fs = boost::filesystem;
fs::path canon = canonical_or_normal(candidate);
boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(candidate, ec);
if (ec) {
canon = fs::absolute(candidate, ec).lexically_normal();
if (ec)
canon = candidate;
}
std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty())
return false;
// Only the part of the path the plugin chose is judged. The components of the allowed root it
// sits in are the host's (on Linux the data directory is ~/.config/OrcaSlicer), so a keyword
// there must not deny everything underneath; a keyword below the root still does, which keeps
// resources_dir()/cert/... unreachable.
fs::path below = canon;
size_t depth = 0;
auto consider = [&](const AllowedRoot& root) {
if (!is_inside_allowed_root(canon, root.path))
return;
const fs::path canon_root = canonical_or_normal(root.path);
const size_t root_depth = static_cast<size_t>(std::distance(canon_root.begin(), canon_root.end()));
if (root_depth > depth) {
depth = root_depth;
below = canon.lexically_relative(canon_root);
}
};
for (const auto& root : m_scoped_allowed_roots)
consider(root);
for (const auto& root : m_global_allowed_roots)
consider(root);
for (const auto& component : below) {
for (const auto& component : canon) {
std::string name = component.string();
if (name.empty())
continue;
+43 -5
View File
@@ -28,6 +28,7 @@
#include <wx/app.h>
#include <wx/defs.h>
#include <wx/thread.h>
#include <wx/timer.h>
#include <wx/toplevel.h>
#include <wx/event.h>
#include <wx/progdlg.h>
@@ -415,6 +416,44 @@ struct UiDockPanelHandle
int id{0};
};
// Polls until the plater is shown on screen, then runs the callback with it (null when the app
// is closing instead) and deletes itself. A plugin loaded at startup opens its panes from
// on_load, before the main window exists, and a pane added before that window is laid out is
// sized against the unsized frame and keeps that width (AuiMgr::track_docked_size).
class PlaterShownWaiter : public wxTimer
{
public:
explicit PlaterShownWaiter(std::function<void(GUI::Plater*)> fn) : m_fn(std::move(fn)) {}
// True once the callback ran.
bool try_run()
{
const bool closing = GUI::wxGetApp().is_closing();
GUI::Plater* plater = closing ? nullptr : GUI::wxGetApp().plater();
if (!closing && (plater == nullptr || !plater->IsShownOnScreen()))
return false;
Stop();
m_fn(plater);
// Off the timer callback's stack: wxGTK's timeout callback still reads the timer after Notify().
GUI::wxGetApp().CallAfter([this]() { delete this; });
return true;
}
void Notify() override { try_run(); }
private:
std::function<void(GUI::Plater*)> m_fn;
};
void run_when_plater_shown(std::function<void(GUI::Plater*)> fn)
{
GUI::wxGetApp().CallAfter([fn = std::move(fn)]() mutable {
auto* waiter = new PlaterShownWaiter(std::move(fn));
if (!waiter->try_run())
waiter->Start(100);
});
}
py::object ui_create_dock_panel(const std::string& html, const std::string& title, int width, int height,
py::object on_message, py::object on_close, const std::string& dock)
{
@@ -434,14 +473,13 @@ py::object ui_create_dock_panel(const std::string& html, const std::string& titl
const int new_id = UiRegistry::instance().reserve_id();
UiRegistry::instance().bind(new_id, nullptr, plugin_key);
GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, dock, w, h,
msg_adapter = std::move(msg_adapter),
close_holder = std::move(close_holder)]() mutable {
run_when_plater_shown([new_id, plugin_key, html, title, dock, w, h,
msg_adapter = std::move(msg_adapter),
close_holder = std::move(close_holder)](GUI::Plater* plater) mutable {
if (!UiRegistry::instance().is_open(new_id))
return;
GUI::Plater* plater = GUI::wxGetApp().plater();
if (plater == nullptr || GUI::wxGetApp().is_closing()) {
if (plater == nullptr) {
UiRegistry::instance().remove(new_id);
return;
}
-38
View File
@@ -253,44 +253,6 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
}
}
TEST_CASE("Plugin audit keyword deny ignores the allowed root's own path", "[audit]")
{
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
// The data directory of a Linux install: its own path carries the "conf" keyword.
const fs::path root = fs::temp_directory_path() / "plugin-audit-xdg" / ".config" / "OrcaSlicer";
mgr.add_global_allowed_root(root.string());
SECTION("a plain file below the root is not denied by the root's components")
{
CHECK_FALSE(mgr.is_denied_path_keyword(root / "log" / "debug.log"));
CHECK_FALSE(mgr.is_denied_path_keyword(root / "orca_plugins" / "plugin_data" / "key" / "state.json"));
}
SECTION("a keyword below the root still denies")
{
CHECK(mgr.is_denied_path_keyword(root / "cert" / "ca.pem"));
CHECK(mgr.is_denied_path_keyword(root / "plugin" / "secrets" / "token.txt"));
CHECK(mgr.is_denied_path_keyword(root / "plugin.conf"));
}
SECTION("a keyword in a path outside every root still denies")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/elsewhere/.config/app/file.txt")));
}
SECTION("inside a plugin context the log is readable and the app config stays blocked")
{
ScopedPluginAuditContext ctx("test_plugin", "");
CHECK(mgr.check_open((root / "log" / "debug.log").string(), "r").allowed);
AuditDecision decision = mgr.check_open((root / (SLIC3R_APP_KEY ".conf")).string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename");
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{
seed_denied_names();