mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 14:08:20 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a24a3fa2 | ||
|
|
f3156b22ac | ||
|
|
93a5fe22cf | ||
|
|
ae36951f99 | ||
|
|
b32856317a | ||
|
|
476a7b1302 | ||
|
|
6070436860 | ||
|
|
f075cdcb98 | ||
|
|
e6db086729 | ||
|
|
f8a718b338 | ||
|
|
a0f2ea0c29 | ||
|
|
3225bfea9d | ||
|
|
5f78baafe2 | ||
|
|
376ad61a5e | ||
|
|
5b81536be2 | ||
|
|
5e3a526b72 | ||
|
|
5f0534ffd4 | ||
|
|
33894bb46b | ||
|
|
17b0eda7f8 | ||
|
|
ec22321a4b | ||
|
|
5e861b31f9 | ||
|
|
334ba8f84d | ||
|
|
418623e2c5 | ||
|
|
cf476637d4 | ||
|
|
ceadec4e2d | ||
|
|
92b3068cf6 | ||
|
|
e45f5846b2 | ||
|
|
f0be15df9e | ||
|
|
0ddb86bec7 | ||
|
|
fe30bfe7c7 | ||
|
|
44675778e2 | ||
|
|
b870835b01 | ||
|
|
ea464401be | ||
|
|
4540a98c58 | ||
|
|
76cf64d6c9 | ||
|
|
8d7df2684d | ||
|
|
62bd8c3743 | ||
|
|
936daee0e6 | ||
|
|
9a161692b5 | ||
|
|
eb071f30f8 | ||
|
|
931b07364a | ||
|
|
34c2c926d1 | ||
|
|
fc56ad002a | ||
|
|
7b75fe02c8 | ||
|
|
2f6de2ed86 | ||
|
|
172f492b74 | ||
|
|
b74fac92a5 |
No files matched your search
@@ -123,7 +123,6 @@ option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0)
|
|||||||
|
|
||||||
# Python stubgen module
|
# Python stubgen module
|
||||||
option(ORCA_BUILD_PYTHON_STUBGEN_MODULE "Build importable Python module for pybind11-stubgen" ON)
|
option(ORCA_BUILD_PYTHON_STUBGEN_MODULE "Build importable Python module for pybind11-stubgen" ON)
|
||||||
option(ORCA_BUILD_PLUGIN_PERMISSION_PROBE "Build the headless Python plugin permission probe (developer tool)" OFF)
|
|
||||||
set(ORCA_BUNDLED_UV_EXECUTABLE "" CACHE FILEPATH "Path to a uv executable to bundle for Python package installation. Leave empty to auto-download.")
|
set(ORCA_BUNDLED_UV_EXECUTABLE "" CACHE FILEPATH "Path to a uv executable to bundle for Python package installation. Leave empty to auto-download.")
|
||||||
|
|
||||||
# Auto-download uv if not provided by the user.
|
# Auto-download uv if not provided by the user.
|
||||||
|
|||||||
@@ -366,7 +366,6 @@ src/slic3r/Utils/MoonrakerPrinterAgent.cpp
|
|||||||
src/slic3r/Utils/BBLNetworkPlugin.cpp
|
src/slic3r/Utils/BBLNetworkPlugin.cpp
|
||||||
src/slic3r/Utils/OrcaCloudServiceAgent.cpp
|
src/slic3r/Utils/OrcaCloudServiceAgent.cpp
|
||||||
src/slic3r/plugin/PluginAuditManager.cpp
|
src/slic3r/plugin/PluginAuditManager.cpp
|
||||||
src/slic3r/plugin/PluginAuditManagerGui.cpp
|
|
||||||
src/slic3r/plugin/PluginConfig.cpp
|
src/slic3r/plugin/PluginConfig.cpp
|
||||||
src/slic3r/plugin/PluginResolver.cpp
|
src/slic3r/plugin/PluginResolver.cpp
|
||||||
src/slic3r/plugin/CloudPluginService.cpp
|
src/slic3r/plugin/CloudPluginService.cpp
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if %FULL_MODE%==1 (
|
|||||||
call :prepareGettextList "%list_file%" "%filtered_list%" "%missing_list%"
|
call :prepareGettextList "%list_file%" "%filtered_list%" "%missing_list%"
|
||||||
if "!has_sources!"=="1" (
|
if "!has_sources!"=="1" (
|
||||||
if not exist "%generated_i18n%" mkdir "%generated_i18n%"
|
if not exist "%generated_i18n%" mkdir "%generated_i18n%"
|
||||||
.\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%"
|
.\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%"
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
set "script_exit_code=1"
|
set "script_exit_code=1"
|
||||||
) else (
|
) else (
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ if $FULL_MODE; then
|
|||||||
generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot"
|
generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot"
|
||||||
|
|
||||||
mkdir -p "$generated_i18n_dir"
|
mkdir -p "$generated_i18n_dir"
|
||||||
xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file"
|
xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file"
|
||||||
python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir"
|
python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir"
|
||||||
|
|
||||||
if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then
|
if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then
|
||||||
|
|||||||
@@ -707,10 +707,6 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
plugin/PluginManager.hpp
|
plugin/PluginManager.hpp
|
||||||
plugin/PluginAuditManager.cpp
|
plugin/PluginAuditManager.cpp
|
||||||
plugin/PluginAuditManager.hpp
|
plugin/PluginAuditManager.hpp
|
||||||
plugin/PluginAuditManagerGui.cpp
|
|
||||||
plugin/PluginAuditManagerInternal.hpp
|
|
||||||
plugin/PluginModule.cpp
|
|
||||||
plugin/PluginText.hpp
|
|
||||||
plugin/PluginResolver.cpp
|
plugin/PluginResolver.cpp
|
||||||
plugin/PluginResolver.hpp
|
plugin/PluginResolver.hpp
|
||||||
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
|
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
|
||||||
@@ -1096,61 +1092,3 @@ if (ORCA_BUILD_PYTHON_STUBGEN_MODULE)
|
|||||||
PREFIX ""
|
PREFIX ""
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Headless plugin permission probe. A distinct executable so normal GUI loading and prompt
|
|
||||||
# behavior stay untouched. It compiles the GUI-free plugin sources directly instead of linking
|
|
||||||
# libslic3r_gui, so neither the GUI library nor wx is pulled into the probe. The permission
|
|
||||||
# front end is selected by source composition: PluginAuditManagerGui.cpp for the GUI app and
|
|
||||||
# probe/PluginAuditManagerHeadless.cpp here.
|
|
||||||
#
|
|
||||||
# Residual cost: the probe still links libslic3r (the large core library) for Utils,
|
|
||||||
# LifecycleEvents, the model/print types used by the GUI-free host bindings and libslic3r's own
|
|
||||||
# transitive dependencies. It does not link libslic3r_gui, wx or the GUI-only host/plugin sources.
|
|
||||||
# Developer/security-analysis tool, so it is off by default.
|
|
||||||
if (ORCA_BUILD_PLUGIN_PERMISSION_PROBE)
|
|
||||||
add_executable(orca-plugin-probe
|
|
||||||
plugin/PluginPermissionProbe.cpp
|
|
||||||
plugin/PluginProbeCollector.hpp
|
|
||||||
plugin/probe/NanoSvgImplementation.cpp
|
|
||||||
plugin/PluginAuditManager.cpp
|
|
||||||
plugin/PluginAuditManagerInternal.hpp
|
|
||||||
plugin/PluginModule.cpp
|
|
||||||
plugin/PluginText.hpp
|
|
||||||
plugin/probe/PluginAuditManagerHeadless.cpp
|
|
||||||
plugin/PluginFsUtils.cpp
|
|
||||||
plugin/PluginLoader.cpp
|
|
||||||
plugin/PythonInterpreter.cpp
|
|
||||||
plugin/PythonPluginBridge.cpp
|
|
||||||
plugin/probe/PluginHostProbe.cpp
|
|
||||||
plugin/probe/PluginProbeConfig.cpp
|
|
||||||
plugin/host/PluginHostGeometry.cpp
|
|
||||||
plugin/host/PluginHostMesh.cpp
|
|
||||||
plugin/host/PluginHostModel.cpp
|
|
||||||
plugin/host/PluginHostSlicing.cpp
|
|
||||||
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp
|
|
||||||
plugin/pluginTypes/pages/PagesPluginCapability.cpp
|
|
||||||
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp
|
|
||||||
plugin/pluginTypes/script/ScriptPluginCapability.cpp
|
|
||||||
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_compile_definitions(orca-plugin-probe PRIVATE ORCA_PLUGIN_PROBE_BUILD)
|
|
||||||
|
|
||||||
target_include_directories(orca-plugin-probe PRIVATE
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}
|
|
||||||
${CMAKE_CURRENT_BINARY_DIR}
|
|
||||||
${CMAKE_SOURCE_DIR}/src
|
|
||||||
Utils
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(orca-plugin-probe PRIVATE
|
|
||||||
libslic3r
|
|
||||||
nanosvg
|
|
||||||
pybind11::embed
|
|
||||||
)
|
|
||||||
|
|
||||||
set_target_properties(orca-plugin-probe PROPERTIES
|
|
||||||
OUTPUT_NAME orca-plugin-probe
|
|
||||||
FOLDER "tools"
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include "slic3r/GUI/wxExtensions.hpp"
|
#include "slic3r/GUI/wxExtensions.hpp"
|
||||||
#include "libslic3r/AppConfig.hpp"
|
#include "libslic3r/AppConfig.hpp"
|
||||||
|
#include "IPrinterAgent.hpp"
|
||||||
|
|
||||||
#include "DeviceCore/DevManager.h"
|
#include "DeviceCore/DevManager.h"
|
||||||
|
|
||||||
@@ -170,14 +171,16 @@ void ConnectPrinterDialog::on_input_enter(wxCommandEvent& evt)
|
|||||||
void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event)
|
void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event)
|
||||||
{
|
{
|
||||||
wxString code = m_textCtrl_code->GetTextCtrl()->GetValue();
|
wxString code = m_textCtrl_code->GetTextCtrl()->GetValue();
|
||||||
if (code.empty() && (!m_obj || m_obj->printer_agent_id != MOONRAKER_PRINTER_AGENT_ID))
|
// Validate only what the user typed; the no-key sentinel is applied afterwards (it contains
|
||||||
code = "88888888";
|
// non-alphanumeric characters, so it must not go through this check).
|
||||||
for (char c : code) {
|
for (char c : code) {
|
||||||
if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) {
|
if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) {
|
||||||
show_error(this, _L("Invalid input"));
|
show_error(this, _L("Invalid input"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (code.empty())
|
||||||
|
code = NO_API_KEY_SENTINEL;
|
||||||
if (m_obj) {
|
if (m_obj) {
|
||||||
m_obj->set_access_code(code.ToStdString());
|
m_obj->set_access_code(code.ToStdString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2685,7 +2685,8 @@ int MachineObject::connect()
|
|||||||
{
|
{
|
||||||
if (m_access_revoked || get_dev_ip().empty()) return -1;
|
if (m_access_revoked || get_dev_ip().empty()) return -1;
|
||||||
std::string username = m_agent ? m_agent->default_lan_username() : std::string();
|
std::string username = m_agent ? m_agent->default_lan_username() : std::string();
|
||||||
std::string password = get_access_code();
|
// The stored access code may be the "no API key" sentinel; agents expect an empty credential.
|
||||||
|
std::string password = normalize_access_code(get_access_code());
|
||||||
|
|
||||||
std::string port;
|
std::string port;
|
||||||
std::string input = get_dev_ip();
|
std::string input = get_dev_ip();
|
||||||
|
|||||||
@@ -4354,6 +4354,11 @@ void GUI_App::select_machine(const std::string& agent_id)
|
|||||||
machine.dev_ip = dev_id;
|
machine.dev_ip = dev_id;
|
||||||
machine.dev_name = dev_id;
|
machine.dev_name = dev_id;
|
||||||
machine.printer_type = preset.config.opt_string("printer_model");
|
machine.printer_type = preset.config.opt_string("printer_model");
|
||||||
|
auto access_code = preset.config.opt_string("printhost_apikey");
|
||||||
|
// Orca expect non empty access code
|
||||||
|
if (access_code.empty()) {
|
||||||
|
access_code = NO_API_KEY_SENTINEL;
|
||||||
|
}
|
||||||
|
|
||||||
existing = m_device_manager->insert_local_device(
|
existing = m_device_manager->insert_local_device(
|
||||||
machine, "lan", "free", "", access_code);
|
machine, "lan", "free", "", access_code);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "I18N.hpp"
|
#include "I18N.hpp"
|
||||||
|
|
||||||
#include "bambu_networking.hpp"
|
#include "bambu_networking.hpp"
|
||||||
|
#include "IPrinterAgent.hpp"
|
||||||
#include "libslic3r/Utils.hpp"
|
#include "libslic3r/Utils.hpp"
|
||||||
#include "GUI.hpp"
|
#include "GUI.hpp"
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
@@ -1903,7 +1904,7 @@ void InputIpAddressDialog::on_ok(wxMouseEvent& evt)
|
|||||||
std::string str_ip = m_input_ip->GetTextCtrl()->GetValue().ToStdString();
|
std::string str_ip = m_input_ip->GetTextCtrl()->GetValue().ToStdString();
|
||||||
std::string str_access_code = m_input_access_code->GetTextCtrl()->GetValue().ToStdString();
|
std::string str_access_code = m_input_access_code->GetTextCtrl()->GetValue().ToStdString();
|
||||||
if (str_access_code.empty())
|
if (str_access_code.empty())
|
||||||
str_access_code = "88888888";
|
str_access_code = NO_API_KEY_SENTINEL;
|
||||||
std::string str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both).ToStdString();
|
std::string str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both).ToStdString();
|
||||||
// Serial number should not contain lower case letters, and bambu_network plugin crashes
|
// Serial number should not contain lower case letters, and bambu_network plugin crashes
|
||||||
// if user entered the wrong serial number, so we call `Upper()` here.
|
// if user entered the wrong serial number, so we call `Upper()` here.
|
||||||
@@ -1950,7 +1951,7 @@ void InputIpAddressDialog::on_send_retry()
|
|||||||
wxString ip = m_input_ip->GetTextCtrl()->GetValue();
|
wxString ip = m_input_ip->GetTextCtrl()->GetValue();
|
||||||
wxString str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
|
wxString str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
|
||||||
if (str_access_code.IsEmpty())
|
if (str_access_code.IsEmpty())
|
||||||
str_access_code = "88888888";
|
str_access_code = NO_API_KEY_SENTINEL;
|
||||||
|
|
||||||
// check support function
|
// check support function
|
||||||
if (!m_obj) return;
|
if (!m_obj) return;
|
||||||
|
|||||||
@@ -211,8 +211,9 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
|
|||||||
int dir = input_val > 0 ? 1 : -1;
|
int dir = input_val > 0 ? 1 : -1;
|
||||||
// i3-arch printers move the bed for Y/Z, so the on-screen direction is
|
// i3-arch printers move the bed for Y/Z, so the on-screen direction is
|
||||||
// reversed -- same negation the g-code fallback below applies.
|
// reversed -- same negation the g-code fallback below applies.
|
||||||
if (!is_core_xy && (axis == "Y" || axis == "Z"))
|
if (!is_core_xy && (axis == "Y" || axis == "Z")) {
|
||||||
dir = -dir;
|
dir = -dir;
|
||||||
|
}
|
||||||
|
|
||||||
j["print"]["command"] = "xyz_ctrl";
|
j["print"]["command"] = "xyz_ctrl";
|
||||||
j["print"]["axis"] = axis;
|
j["print"]["axis"] = axis;
|
||||||
@@ -222,8 +223,9 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
|
|||||||
}
|
}
|
||||||
|
|
||||||
double value = input_val;
|
double value = input_val;
|
||||||
if (!is_core_xy && (axis == "Y" || axis == "Z"))
|
if (!is_core_xy && (axis == "Y" || axis == "Z")) {
|
||||||
value = -input_val;
|
value = -1.0 * input_val;
|
||||||
|
}
|
||||||
|
|
||||||
std::string value_str = (boost::format("%.1f") % (value * unit)).str();
|
std::string value_str = (boost::format("%.1f") % (value * unit)).str();
|
||||||
std::string gcode;
|
std::string gcode;
|
||||||
@@ -244,10 +246,11 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
|
|||||||
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
|
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
|
||||||
{
|
{
|
||||||
const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0);
|
const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0);
|
||||||
if (rtn == 0)
|
if (rtn == 0) {
|
||||||
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
|
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
|
||||||
else
|
} else {
|
||||||
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
|
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
|
||||||
|
}
|
||||||
return rtn;
|
return rtn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,8 +596,21 @@ int BBLPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateS
|
|||||||
|
|
||||||
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
|
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
|
||||||
{
|
{
|
||||||
return dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
|
// dispatch_start() moves out of `params`, so snapshot the diagnostic fields first;
|
||||||
|
// logging them after the call would print empty strings.
|
||||||
|
const bool try_emmc_print = params.try_emmc_print;
|
||||||
|
const std::string dev_ip = params.dev_ip;
|
||||||
|
const std::string dev_id = params.dev_id;
|
||||||
|
|
||||||
|
int result = dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
|
||||||
BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn);
|
BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn);
|
||||||
|
if (result != 0) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "start_send_gcode_to_sdcard failed: result=" << result
|
||||||
|
<< ", try_emmc_print=" << try_emmc_print
|
||||||
|
<< ", legacy_mode=" << BBLNetworkPlugin::instance().use_legacy_network()
|
||||||
|
<< ", dev_ip=" << dev_ip << ", dev_id=" << dev_id;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
|
int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ public:
|
|||||||
static std::string from_orca_payload(std::string json_text);
|
static std::string from_orca_payload(std::string json_text);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json.
|
||||||
int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
|
int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -288,22 +288,27 @@ bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
|
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
|
||||||
{
|
{
|
||||||
if (device_info.dev_ip.empty()) {
|
if (sync_mode != get_filament_sync_mode())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const MoonrakerDeviceInfo info = snapshot_device_info();
|
||||||
|
|
||||||
|
if (info.dev_ip.empty()) {
|
||||||
BOOST_LOG_TRIVIAL(warning)
|
BOOST_LOG_TRIVIAL(warning)
|
||||||
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
|
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
|
||||||
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a CrealityPrint helper so we can use its model detection + WS helpers
|
// Build a CrealityPrint helper so we can use its model detection + WS helpers
|
||||||
// (added in upstream PR #13291).
|
// (added in upstream PR #13291).
|
||||||
DynamicPrintConfig cfg;
|
DynamicPrintConfig cfg;
|
||||||
cfg.set_key_value("print_host", new ConfigOptionString("http://" + device_info.dev_ip));
|
cfg.set_key_value("print_host", new ConfigOptionString("http://" + info.dev_ip));
|
||||||
cfg.set_key_value("print_host_webui", new ConfigOptionString(""));
|
cfg.set_key_value("print_host_webui", new ConfigOptionString(""));
|
||||||
cfg.set_key_value("printhost_cafile", new ConfigOptionString(""));
|
cfg.set_key_value("printhost_cafile", new ConfigOptionString(""));
|
||||||
cfg.set_key_value("printhost_port", new ConfigOptionString(""));
|
cfg.set_key_value("printhost_port", new ConfigOptionString(""));
|
||||||
cfg.set_key_value("printhost_apikey", new ConfigOptionString(device_info.api_key));
|
cfg.set_key_value("printhost_apikey", new ConfigOptionString(info.api_key));
|
||||||
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
|
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
|
||||||
|
|
||||||
CrealityPrint host(&cfg);
|
CrealityPrint host(&cfg);
|
||||||
@@ -313,7 +318,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
|
|||||||
BOOST_LOG_TRIVIAL(info)
|
BOOST_LOG_TRIVIAL(info)
|
||||||
<< "CrealityPrintAgent: " << host.model_name()
|
<< "CrealityPrintAgent: " << host.model_name()
|
||||||
<< " is not CFS-capable, deferring to base Moonraker agent";
|
<< " is not CFS-capable, deferring to base Moonraker agent";
|
||||||
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info)
|
BOOST_LOG_TRIVIAL(info)
|
||||||
@@ -328,7 +333,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
|
|||||||
BOOST_LOG_TRIVIAL(warning)
|
BOOST_LOG_TRIVIAL(warning)
|
||||||
<< "CrealityPrintAgent: CFS query failed (" << parse_err << "), "
|
<< "CrealityPrintAgent: CFS query failed (" << parse_err << "), "
|
||||||
<< "falling back to base agent";
|
<< "falling back to base agent";
|
||||||
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (box_count == 0) {
|
if (box_count == 0) {
|
||||||
@@ -337,7 +342,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
|
|||||||
// Moonraker exposes.
|
// Moonraker exposes.
|
||||||
BOOST_LOG_TRIVIAL(info)
|
BOOST_LOG_TRIVIAL(info)
|
||||||
<< "CrealityPrintAgent: no active CFS boxes, deferring to base agent";
|
<< "CrealityPrintAgent: no active CFS boxes, deferring to base agent";
|
||||||
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info)
|
BOOST_LOG_TRIVIAL(info)
|
||||||
@@ -382,7 +387,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
build_ams_payload(box_count, max_slots - 1, trays);
|
build_ams_payload(box_count, max_slots - 1, trays, sync_mode == FilamentSyncMode::pull);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,13 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
explicit CrealityPrintAgent(std::string log_dir);
|
explicit CrealityPrintAgent(std::string log_dir);
|
||||||
~CrealityPrintAgent() override = default;
|
~CrealityPrintAgent() override { shutdown(); }
|
||||||
|
|
||||||
static AgentInfo get_agent_info_static();
|
static AgentInfo get_agent_info_static();
|
||||||
AgentInfo get_agent_info() override { return get_agent_info_static(); }
|
AgentInfo get_agent_info() override { return get_agent_info_static(); }
|
||||||
|
|
||||||
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
||||||
|
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
|
||||||
|
|
||||||
// Parse the boxsInfo JSON returned by CrealityPrint::query_boxes_info() into
|
// Parse the boxsInfo JSON returned by CrealityPrint::query_boxes_info() into
|
||||||
// a flat list of loaded slots, plus the count of CFS boxes the printer reports.
|
// a flat list of loaded slots, plus the count of CFS boxes the printer reports.
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ struct PrinterConnectionParams
|
|||||||
std::string ca_file;
|
std::string ca_file;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sentinel stored as a LAN device's access code when the user has not supplied one, so the device
|
||||||
|
// stays registered (has_access_right() is !access_code.empty()). It is not a real credential: every
|
||||||
|
// printer agent must translate it back to an empty string before using/sending it on the wire.
|
||||||
|
inline constexpr const char* NO_API_KEY_SENTINEL = "*_{NO_API_KEY}_*";
|
||||||
|
|
||||||
|
// Returns an empty string when `code` is the "no API key" sentinel, otherwise `code` unchanged.
|
||||||
|
inline std::string normalize_access_code(const std::string& code)
|
||||||
|
{
|
||||||
|
return code == NO_API_KEY_SENTINEL ? std::string() : code;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FilamentSyncMode - Modes for filament data synchronization.
|
* FilamentSyncMode - Modes for filament data synchronization.
|
||||||
*
|
*
|
||||||
@@ -455,9 +466,16 @@ public:
|
|||||||
virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; }
|
virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh filament info from the printer synchronously.
|
* Refresh filament info from the printer.
|
||||||
* Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull.
|
*
|
||||||
* Populates the MachineObject's DevFilaSystem with fetched filament data.
|
* When called with FilamentSyncMode::pull (which requires get_filament_sync_mode() to
|
||||||
|
* return pull) this is a blocking, synchronous call: when it returns true the MachineObject's
|
||||||
|
* DevFilaSystem has already been populated and the caller may read it immediately.
|
||||||
|
*
|
||||||
|
* When called with FilamentSyncMode::subscription — by an implementation's own status loop —
|
||||||
|
* the refresh may be performed asynchronously: a true return then means the refresh was
|
||||||
|
* scheduled (or is already in flight), and DevFilaSystem is updated later on the main thread.
|
||||||
|
* Callers must not assume the data is ready on return.
|
||||||
*/
|
*/
|
||||||
virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; }
|
virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,8 @@
|
|||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <deque>
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -21,6 +23,56 @@ namespace Slic3r { class ICloudServiceAgent; }
|
|||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
|
class Http;
|
||||||
|
|
||||||
|
bool moonraker_is_light_name(const std::string& name);
|
||||||
|
// Direction encoded by a light name: +1 turns on, -1 turns off, 0 is an ambiguous toggle.
|
||||||
|
// e.g. LIGHT_ON -> +1, LIGHT_OFF -> -1, LIGHT -> 0.
|
||||||
|
int moonraker_light_name_direction(const std::string& name);
|
||||||
|
|
||||||
|
struct MoonrakerWebcamSelection
|
||||||
|
{
|
||||||
|
std::string url;
|
||||||
|
CameraStreamMode mode = CameraStreamMode::none;
|
||||||
|
std::string name;
|
||||||
|
std::string error; // set when no selectable webcam was found
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selects a webcam from a parsed /server/webcams/list response (the top-level JSON or its
|
||||||
|
// "result" value). Returns true and fills url/mode/name on success; on failure returns
|
||||||
|
// false and sets error.
|
||||||
|
bool moonraker_parse_webcam_list(const nlohmann::json& response, const std::string& base_url,
|
||||||
|
MoonrakerWebcamSelection& out);
|
||||||
|
|
||||||
|
class MoonrakerWebsocket
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
enum class ReadResult
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
timeout,
|
||||||
|
closed,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
MoonrakerWebsocket(bool secure, std::string api_key, std::string ca_file);
|
||||||
|
~MoonrakerWebsocket();
|
||||||
|
|
||||||
|
void connect(const std::string& host, const std::string& port, std::chrono::seconds timeout);
|
||||||
|
void tls_handshake(const std::string& host);
|
||||||
|
void handshake(const std::string& host, const std::string& target);
|
||||||
|
void text(bool enabled);
|
||||||
|
void write(const std::string& body);
|
||||||
|
ReadResult read(std::string& payload, std::string& error_message);
|
||||||
|
void close();
|
||||||
|
void expires_after(std::chrono::seconds timeout);
|
||||||
|
void abort();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> m_impl;
|
||||||
|
};
|
||||||
|
|
||||||
class MoonrakerPrinterAgent : public IPrinterAgent
|
class MoonrakerPrinterAgent : public IPrinterAgent
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -66,12 +118,26 @@ public:
|
|||||||
int set_on_local_connect_fn(OnLocalConnectedFn fn) override;
|
int set_on_local_connect_fn(OnLocalConnectedFn fn) override;
|
||||||
int set_on_local_message_fn(OnMessageFn fn) override;
|
int set_on_local_message_fn(OnMessageFn fn) override;
|
||||||
int set_queue_on_main_fn(QueueOnMainFn fn) override;
|
int set_queue_on_main_fn(QueueOnMainFn fn) override;
|
||||||
|
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
||||||
// Pull-mode agent (on-demand filament sync)
|
// Pull-mode agent (on-demand filament sync)
|
||||||
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
|
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
|
||||||
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
CameraStreamMode get_camera_stream_mode() const override;
|
||||||
|
std::string get_camera_url() const override;
|
||||||
|
|
||||||
|
// Called by the fetch-thread RAII guard when a background filament fetch finishes.
|
||||||
|
// Serialized with the reservation so shutdown()'s wait cannot miss the transition to 0.
|
||||||
|
void release_fetch_slot() noexcept;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
struct ConnectionSettings
|
||||||
|
{
|
||||||
|
std::string dev_id;
|
||||||
|
std::string base_url;
|
||||||
|
std::string api_key;
|
||||||
|
bool use_ssl = false;
|
||||||
|
std::string ca_file;
|
||||||
|
};
|
||||||
|
|
||||||
struct MoonrakerDeviceInfo
|
struct MoonrakerDeviceInfo
|
||||||
{
|
{
|
||||||
std::string dev_id;
|
std::string dev_id;
|
||||||
@@ -83,7 +149,9 @@ protected:
|
|||||||
std::string dev_name;
|
std::string dev_name;
|
||||||
std::string version;
|
std::string version;
|
||||||
std::string klippy_state;
|
std::string klippy_state;
|
||||||
|
float nozzle_diameter = 0.0f;
|
||||||
bool use_ssl = false;
|
bool use_ssl = false;
|
||||||
|
std::string ca_file;
|
||||||
} device_info;
|
} device_info;
|
||||||
|
|
||||||
// Copied when the stream starts, so changes apply to the next start_status_stream()
|
// Copied when the stream starts, so changes apply to the next start_status_stream()
|
||||||
@@ -107,18 +175,47 @@ protected:
|
|||||||
int nozzle_temp = 0; // Optional
|
int nozzle_temp = 0; // Optional
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build ams JSON and call parser
|
// Build ams JSON and call parser.
|
||||||
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
|
// apply_inline: when true the MachineObject mutation runs on the calling thread, which
|
||||||
|
// must therefore be the main thread; when false it is deferred through queue_on_main_fn.
|
||||||
|
// Pull-mode fetches run synchronously on the GUI thread and are read back immediately, so
|
||||||
|
// they pass true to avoid the deferred mutation landing after the caller's read.
|
||||||
|
//
|
||||||
|
// resolve_tray_ids: optional hook that runs as part of the commit, i.e. on the main thread.
|
||||||
|
// Agents that would otherwise resolve tray_info_idx against GUI-owned preset state on a
|
||||||
|
// background fetch thread must do it here to avoid racing the GUI. It must not capture
|
||||||
|
// `this` (the commit may outlive the agent).
|
||||||
|
using ResolveAmsTrayIdsFn = std::function<void(std::vector<AmsTrayData>&)>;
|
||||||
|
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, bool apply_inline = false,
|
||||||
|
ResolveAmsTrayIdsFn resolve_tray_ids = {});
|
||||||
|
|
||||||
// Methods that derived classes may need to override or access
|
// Methods that derived classes may need to override or access
|
||||||
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
|
virtual bool init_device_info(const PrinterConnectionParams& params);
|
||||||
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
|
virtual bool fetch_device_info(const ConnectionSettings& connection, MoonrakerDeviceInfo& info, std::string& error) const;
|
||||||
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
ConnectionSettings get_connection_settings() const;
|
||||||
|
// Copy of the mutable connection state, taken under connect_mutex. Background threads
|
||||||
|
// must use this instead of reading device_info directly.
|
||||||
|
MoonrakerDeviceInfo snapshot_device_info() const;
|
||||||
|
void configure_http(Http& http, const ConnectionSettings& connection) const;
|
||||||
|
static float parse_nozzle_diameter(const nlohmann::json& response);
|
||||||
|
// The connection is copied when the stream starts, so changes apply to the next start.
|
||||||
|
void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
|
||||||
void stop_status_stream();
|
void stop_status_stream();
|
||||||
|
|
||||||
// State access for derived classes
|
// State access for derived classes
|
||||||
mutable std::recursive_mutex state_mutex;
|
mutable std::recursive_mutex state_mutex;
|
||||||
|
|
||||||
|
// Detached fetch threads hold a raw `this`; shutdown() waits for this to reach 0.
|
||||||
|
std::atomic<int> filament_fetch_in_flight{0};
|
||||||
|
|
||||||
|
// Idempotent teardown; must be called from the most-derived destructor.
|
||||||
|
void shutdown();
|
||||||
|
std::atomic<bool> shutting_down{false};
|
||||||
|
|
||||||
|
// Serializes the shutting_down check with the in-flight reservation.
|
||||||
|
std::mutex fetch_lifecycle_mutex;
|
||||||
|
std::condition_variable fetch_done_cv; // notified when filament_fetch_in_flight reaches 0
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
bool is_numeric(const std::string& value);
|
bool is_numeric(const std::string& value);
|
||||||
std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port);
|
std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port);
|
||||||
@@ -131,13 +228,30 @@ protected:
|
|||||||
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
|
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
|
||||||
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
|
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
|
||||||
|
|
||||||
|
// Send a G-code script via Moonraker (/printer/gcode/script)
|
||||||
|
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
|
||||||
|
bool send_gcode(const std::string& dev_id, const std::string& gcode,
|
||||||
|
const ConnectionSettings& connection) const;
|
||||||
|
bool post_print_action(const std::string& action) const;
|
||||||
|
bool post_print_action(const std::string& action,
|
||||||
|
const ConnectionSettings& connection) const;
|
||||||
|
|
||||||
|
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
|
||||||
|
|
||||||
|
virtual void on_status_loop_tick(const std::string& dev_id) {}
|
||||||
|
|
||||||
|
// Queue work that may use agent state. The command worker is joined during
|
||||||
|
// destruction, so queued commands cannot outlive the agent.
|
||||||
|
void enqueue_command(std::function<void()> fn);
|
||||||
|
mutable std::recursive_mutex connect_mutex;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int handle_request(const std::string& dev_id, const std::string& json_str);
|
int handle_request(const std::string& dev_id, const std::string& json_str);
|
||||||
int send_version_info(const std::string& dev_id);
|
int send_version_info(const std::string& dev_id);
|
||||||
int send_access_code(const std::string& dev_id);
|
int send_access_code(const std::string& dev_id);
|
||||||
|
|
||||||
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const;
|
bool fetch_object_list(const ConnectionSettings& connection, std::set<std::string>& objects, std::string& error) const;
|
||||||
bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const;
|
bool query_printer_status(const ConnectionSettings& connection, nlohmann::json& status, std::string& error) const;
|
||||||
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const;
|
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const;
|
||||||
void send_gcode_async(const std::string& dev_id, const std::string& gcode,
|
void send_gcode_async(const std::string& dev_id, const std::string& gcode,
|
||||||
std::function<void(bool)> on_result = {}) const;
|
std::function<void(bool)> on_result = {}) const;
|
||||||
@@ -145,38 +259,46 @@ private:
|
|||||||
void announce_printhost_device();
|
void announce_printhost_device();
|
||||||
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
|
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
|
||||||
void dispatch_printer_connected(const std::string& dev_id);
|
void dispatch_printer_connected(const std::string& dev_id);
|
||||||
|
|
||||||
|
// Self-contained snapshot of the message callbacks. Async completions capture this
|
||||||
|
// by value so they never dereference `this` after the agent may have been destroyed.
|
||||||
|
struct MessageRouter
|
||||||
|
{
|
||||||
|
OnMessageFn local_fn;
|
||||||
|
OnMessageFn cloud_fn;
|
||||||
|
QueueOnMainFn queue_fn;
|
||||||
|
std::string dev_id;
|
||||||
|
|
||||||
|
void deliver(std::string payload) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageRouter make_message_router(const std::string& dev_id) const;
|
||||||
void dispatch_message(const std::string& dev_id, const std::string& payload);
|
void dispatch_message(const std::string& dev_id, const std::string& payload);
|
||||||
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
|
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
|
||||||
void join_stopped_status_stream();
|
void join_stopped_status_stream();
|
||||||
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
|
void run_status_stream(std::string dev_id, ConnectionSettings connection, StatusStreamTimings timings);
|
||||||
void handle_ws_message(const std::string& dev_id, const std::string& payload);
|
void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
|
||||||
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
|
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
|
||||||
|
void refresh_thumbnail_url(const ConnectionSettings& connection);
|
||||||
void update_status_cache(const nlohmann::json& updates);
|
void update_status_cache(const nlohmann::json& updates);
|
||||||
nlohmann::json build_print_payload_locked() const;
|
nlohmann::json build_print_payload_locked() const;
|
||||||
|
|
||||||
// Print control helpers
|
|
||||||
int pause_print(const std::string& dev_id);
|
|
||||||
int resume_print(const std::string& dev_id);
|
|
||||||
int cancel_print(const std::string& dev_id);
|
|
||||||
|
|
||||||
// File upload
|
// File upload
|
||||||
bool upload_gcode(const std::string& local_path, const std::string& filename,
|
bool upload_gcode(const std::string& local_path, const std::string& filename,
|
||||||
const std::string& base_url, const std::string& api_key,
|
const ConnectionSettings& connection,
|
||||||
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
|
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
|
||||||
|
|
||||||
// JSON-RPC helper
|
|
||||||
bool send_jsonrpc_command(const std::string& base_url, const std::string& api_key,
|
|
||||||
const nlohmann::json& request, std::string& response) const;
|
|
||||||
|
|
||||||
// Connection thread management
|
// Connection thread management
|
||||||
void perform_connection_async(const std::string& dev_id,
|
void perform_connection_async(const std::string& dev_id,
|
||||||
const std::string& base_url,
|
ConnectionSettings connection,
|
||||||
const std::string& api_key,
|
|
||||||
uint64_t generation);
|
uint64_t generation);
|
||||||
|
|
||||||
|
void refresh_webcam_info() const;
|
||||||
|
bool fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const;
|
||||||
|
|
||||||
// System-specific filament fetch methods
|
// System-specific filament fetch methods
|
||||||
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index);
|
bool fetch_hh_filament_info(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
|
||||||
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
|
bool fetch_moonraker_filament_data(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
|
||||||
|
|
||||||
// JSON helper methods
|
// JSON helper methods
|
||||||
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
|
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
|
||||||
@@ -202,16 +324,37 @@ private:
|
|||||||
|
|
||||||
mutable std::recursive_mutex payload_mutex;
|
mutable std::recursive_mutex payload_mutex;
|
||||||
nlohmann::json status_cache;
|
nlohmann::json status_cache;
|
||||||
|
// note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found
|
||||||
|
std::string thumbnail_filename;
|
||||||
|
std::string thumbnail_url;
|
||||||
|
mutable std::string webcam_stream_url;
|
||||||
|
mutable CameraStreamMode webcam_stream_mode = CameraStreamMode::none;
|
||||||
|
// Next time the status loop may look the webcam up again (steady_clock ms).
|
||||||
|
mutable std::atomic<uint64_t> webcam_info_next_attempt_ms{0};
|
||||||
|
unsigned thumbnail_lookup_attempts = 0;
|
||||||
|
|
||||||
|
static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 30000;
|
||||||
|
static constexpr uint64_t WEBCAM_INFO_FAILURE_BACKOFF_MS = 60000;
|
||||||
|
|
||||||
std::atomic<int> next_jsonrpc_id{1};
|
std::atomic<int> next_jsonrpc_id{1};
|
||||||
std::set<std::string> available_objects; // Track for feature detection
|
std::set<std::string> available_objects; // Track for feature detection
|
||||||
|
bool assumed_light_on = false;
|
||||||
|
|
||||||
std::atomic<bool> ws_stop{false};
|
std::atomic<bool> ws_stop{false};
|
||||||
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
|
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
|
||||||
std::atomic<uint64_t> ws_last_emit_ms{0};
|
std::atomic<uint64_t> ws_last_emit_ms{0};
|
||||||
std::thread ws_thread;
|
std::thread ws_thread;
|
||||||
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
|
|
||||||
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
|
// Guards ws_thread and ws_abort. ws_abort posts to the stream's io_context, cancelling the
|
||||||
|
// reconnect wait and interrupting whatever connection attempt is running, so stop is prompt
|
||||||
|
// even when the stream thread is blocked on connect, TLS, or a read.
|
||||||
|
std::mutex ws_abort_mutex;
|
||||||
|
std::function<void()> ws_abort; // guarded by ws_abort_mutex
|
||||||
|
|
||||||
|
// AMS/filament refresh cadence, independent of telemetry dispatch so a steady
|
||||||
|
// stream of status updates can't starve it (ws_last_emit_ms is reset by those).
|
||||||
|
static constexpr uint64_t AMS_REFRESH_INTERVAL_MS = 10000;
|
||||||
|
std::atomic<uint64_t> ams_last_fetch_ms{0};
|
||||||
|
|
||||||
// Throttling configuration for WebSocket updates
|
// Throttling configuration for WebSocket updates
|
||||||
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
|
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
|
||||||
@@ -222,7 +365,13 @@ private:
|
|||||||
// Connection thread management
|
// Connection thread management
|
||||||
std::atomic<uint64_t> connect_generation{0};
|
std::atomic<uint64_t> connect_generation{0};
|
||||||
std::thread connect_thread;
|
std::thread connect_thread;
|
||||||
std::recursive_mutex connect_mutex;
|
|
||||||
|
void run_command_worker();
|
||||||
|
std::thread cmd_thread;
|
||||||
|
std::deque<std::function<void()>> cmd_queue;
|
||||||
|
std::mutex cmd_mutex;
|
||||||
|
std::condition_variable cmd_cv;
|
||||||
|
bool cmd_stop = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
#include "Http.hpp"
|
#include "Http.hpp"
|
||||||
#include "MoonrakerPrinterAgent.hpp"
|
#include "MoonrakerPrinterAgent.hpp"
|
||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
|
#include "bambu_networking.hpp"
|
||||||
#include "libslic3r/PresetBundle.hpp"
|
#include "libslic3r/PresetBundle.hpp"
|
||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
|
|
||||||
#include "nlohmann/json.hpp"
|
#include "nlohmann/json.hpp"
|
||||||
|
#include <atomic>
|
||||||
#include <boost/algorithm/string.hpp>
|
#include <boost/algorithm/string.hpp>
|
||||||
#include <boost/algorithm/string/trim.hpp>
|
#include <boost/algorithm/string/trim.hpp>
|
||||||
#include <boost/algorithm/string/case_conv.hpp>
|
#include <boost/algorithm/string/case_conv.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
#include "libslic3r/Preset.hpp"
|
#include "libslic3r/Preset.hpp"
|
||||||
|
#include <cstddef>
|
||||||
|
#include <exception>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <thread>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -34,6 +41,40 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RAII release of the in-flight fetch slot; movable so a failed thread start still releases it.
|
||||||
|
struct InFlightGuard
|
||||||
|
{
|
||||||
|
MoonrakerPrinterAgent* owner;
|
||||||
|
explicit InFlightGuard(MoonrakerPrinterAgent& o) noexcept : owner(&o) {}
|
||||||
|
InFlightGuard(InFlightGuard&& other) noexcept : owner(other.owner) { other.owner = nullptr; }
|
||||||
|
InFlightGuard(const InFlightGuard&) = delete;
|
||||||
|
InFlightGuard& operator=(const InFlightGuard&) = delete;
|
||||||
|
InFlightGuard& operator=(InFlightGuard&&) = delete;
|
||||||
|
~InFlightGuard() { if (owner) owner->release_fetch_slot(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// nlohmann::json::value() returns the default only when the key is absent; a present but
|
||||||
|
// null/wrong-typed value throws. Firmware JSON is untrusted, so read defensively.
|
||||||
|
int read_int_or(const nlohmann::json& obj, const std::string& key, int fallback)
|
||||||
|
{
|
||||||
|
auto it = obj.find(key);
|
||||||
|
if (it == obj.end()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (it->is_number_integer()) {
|
||||||
|
return it->get<int>();
|
||||||
|
}
|
||||||
|
// Firmware may report an integral field as a JSON float (e.g. 2.0). Accept it only when it
|
||||||
|
// has no fractional part, so a genuinely fractional value falls back instead of truncating.
|
||||||
|
if (it->is_number_float()) {
|
||||||
|
const double value = it->get<double>();
|
||||||
|
if (value == std::floor(value)) {
|
||||||
|
return static_cast<int>(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
const std::string QidiPrinterAgent_VERSION = "0.0.1";
|
const std::string QidiPrinterAgent_VERSION = "0.0.1";
|
||||||
@@ -47,51 +88,179 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
|
|||||||
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
|
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
|
FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const
|
||||||
{
|
{
|
||||||
std::string error;
|
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
|
||||||
|
return FilamentSyncMode::subscription;
|
||||||
|
return FilamentSyncMode::pull;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Fetch device info and infer series_id
|
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
|
||||||
std::string series_id;
|
{
|
||||||
{
|
if (sync_mode != get_filament_sync_mode())
|
||||||
MoonrakerDeviceInfo info;
|
|
||||||
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
|
|
||||||
series_id = infer_series_id(info.model_id, info.dev_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (series_id.empty()) {
|
|
||||||
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
|
|
||||||
series_id = infer_series_id(device_info.model_id, device_info.model_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch filament dictionary
|
|
||||||
QidiFilamentDict dict;
|
|
||||||
if (!fetch_filament_dict(device_info.base_url, device_info.api_key, dict, error)) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch slot info and build AmsTrayData directly
|
|
||||||
std::vector<AmsTrayData> trays;
|
|
||||||
int box_count = 0;
|
|
||||||
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
// Snapshot what the fetch needs; a reconnect can rewrite device_info meanwhile.
|
||||||
|
ConnectionSettings connection = get_connection_settings();
|
||||||
|
std::string model_id;
|
||||||
|
std::string model_name;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
|
||||||
|
model_id = device_info.model_id;
|
||||||
|
model_name = device_info.model_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Build the AMS payload
|
// One implementation for both modes; only where it runs differs. pull is the documented
|
||||||
build_ams_payload(box_count, box_count * 4 - 1, trays);
|
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
|
||||||
|
// on the caller with the payload applied inline. subscription runs on a background thread
|
||||||
|
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
|
||||||
|
auto work = [this, connection = std::move(connection), model_id, model_name](bool apply_inline) -> bool {
|
||||||
|
try {
|
||||||
|
std::string error;
|
||||||
|
|
||||||
|
// 1. Fetch device info and infer series_id
|
||||||
|
std::string series_id;
|
||||||
|
{
|
||||||
|
MoonrakerDeviceInfo info;
|
||||||
|
if (fetch_device_info(connection, info, error)) {
|
||||||
|
series_id = infer_series_id(info.model_id, info.dev_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (series_id.empty()) {
|
||||||
|
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
|
||||||
|
series_id = infer_series_id(model_id, model_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch filament dictionary
|
||||||
|
QidiFilamentDict dict;
|
||||||
|
if (!fetch_filament_dict(connection, dict, error)) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch slot info and build AmsTrayData directly
|
||||||
|
std::vector<AmsTrayData> trays;
|
||||||
|
int box_count = 0;
|
||||||
|
if (!fetch_slot_info(connection, dict, series_id, trays, box_count, error)) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the AMS payload, resolving preset-dependent ids on the main thread.
|
||||||
|
auto resolve_ids = [](std::vector<AmsTrayData>& resolved_trays) {
|
||||||
|
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||||
|
if (!bundle) {
|
||||||
|
return; // keep the Qidi-specific ids computed from device data
|
||||||
|
}
|
||||||
|
for (auto& tray : resolved_trays) {
|
||||||
|
if (!tray.has_filament) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!tray.tray_info_idx.empty() && has_visible_base_preset(bundle->filaments, tray.tray_info_idx)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
build_ams_payload(box_count, box_count * 4 - 1, trays, apply_inline, resolve_ids);
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
// why: an exception escaping a detached thread is std::terminate, and firmware
|
||||||
|
// JSON is untrusted; mirror run_command_worker and swallow it here.
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
|
||||||
|
return false;
|
||||||
|
} catch (...) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sync_mode == FilamentSyncMode::pull) {
|
||||||
|
return work(/*apply_inline=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
|
||||||
|
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
|
||||||
|
// shutdown() uses, so the flag and the count can't race.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
|
||||||
|
if (shutting_down.load())
|
||||||
|
return false;
|
||||||
|
if (filament_fetch_in_flight.load() > 0)
|
||||||
|
return true; // a fetch is already running; don't pile on
|
||||||
|
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
InFlightGuard guard{*this};
|
||||||
|
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
|
||||||
|
work(/*apply_inline=*/false);
|
||||||
|
}).detach();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
|
||||||
const std::string& api_key,
|
{
|
||||||
|
// enable_box mirrors task_use_ams: engage the multi-color box only when this
|
||||||
|
// job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 —
|
||||||
|
// if firmware treats enable_box as "a box exists" rather than "use it this job",
|
||||||
|
// switch this gate to HasAms()/box_count instead.)
|
||||||
|
const int enable = params.task_use_ams ? 1 : 0;
|
||||||
|
const std::string dev_id = get_connection_settings().dev_id;
|
||||||
|
|
||||||
|
// Build one gcode/script request instead of N blocking HTTP calls: apply_box_mapping
|
||||||
|
// runs on the caller's (GUI) thread before the print starts, so per-tool round trips
|
||||||
|
// would freeze the UI.
|
||||||
|
std::string script = "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable);
|
||||||
|
|
||||||
|
// When the box isn't used this job, leave the existing value_t<tool> slot
|
||||||
|
// assignments untouched (enable_box=0 is enough to disengage it).
|
||||||
|
if (enable) {
|
||||||
|
if (params.ams_mapping.empty()) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
|
||||||
|
} else {
|
||||||
|
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
|
||||||
|
// physical box slot (-1 = unmapped). Mirror it onto value_t<tool>.
|
||||||
|
auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false);
|
||||||
|
if (mapping.is_discarded() || !mapping.is_array()) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (size_t tool = 0; tool < mapping.size(); ++tool) {
|
||||||
|
if (!mapping[tool].is_number_integer())
|
||||||
|
continue;
|
||||||
|
const int slot = mapping[tool].get<int>();
|
||||||
|
if (slot < 0)
|
||||||
|
continue; // unmapped filament — skip
|
||||||
|
script += "\nSAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
|
||||||
|
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!send_gcode(dev_id, script)) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to send box mapping";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
|
||||||
|
{
|
||||||
|
// Only the LAN print path is implemented by the Moonraker base; the box config is emitted
|
||||||
|
// here, before the print starts. The cloud/record/sdcard variants inherited from the base
|
||||||
|
// deliberately return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED without side effects.
|
||||||
|
if (!apply_box_mapping(params))
|
||||||
|
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
|
||||||
|
return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
||||||
const QidiFilamentDict& dict,
|
const QidiFilamentDict& dict,
|
||||||
const std::string& series_id,
|
const std::string& series_id,
|
||||||
std::vector<AmsTrayData>& trays,
|
std::vector<AmsTrayData>& trays,
|
||||||
int& box_count,
|
int& box_count,
|
||||||
std::string& error)
|
std::string& error)
|
||||||
{
|
{
|
||||||
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables");
|
std::string url = join_url(connection.base_url, "/printer/objects/query?save_variables=variables");
|
||||||
for (int i = 0; i < 16; ++i) {
|
for (int i = 0; i < 16; ++i) {
|
||||||
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
|
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
|
||||||
}
|
}
|
||||||
@@ -101,8 +270,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
|||||||
std::string http_error;
|
std::string http_error;
|
||||||
|
|
||||||
auto http = Http::get(url);
|
auto http = Http::get(url);
|
||||||
if (!api_key.empty()) {
|
configure_http(http, connection);
|
||||||
http.header("X-Api-Key", api_key);
|
if (!connection.api_key.empty()) {
|
||||||
|
http.header("X-Api-Key", connection.api_key);
|
||||||
}
|
}
|
||||||
http.timeout_connect(5)
|
http.timeout_connect(5)
|
||||||
.timeout_max(10)
|
.timeout_max(10)
|
||||||
@@ -127,22 +297,12 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
nlohmann::json status;
|
||||||
if (json.is_discarded()) {
|
nlohmann::json variables;
|
||||||
error = "Invalid JSON response";
|
if (!parse_slot_response(response_body, status, variables, error))
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
|
box_count = read_int_or(variables, "box_count", 1);
|
||||||
!json["result"]["status"]["save_variables"].contains("variables")) {
|
|
||||||
error = "Unexpected JSON structure";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& variables = json["result"]["status"]["save_variables"]["variables"];
|
|
||||||
auto& status = json["result"]["status"];
|
|
||||||
|
|
||||||
box_count = variables.value("box_count", 1);
|
|
||||||
if (box_count < 0) {
|
if (box_count < 0) {
|
||||||
box_count = 0;
|
box_count = 0;
|
||||||
}
|
}
|
||||||
@@ -165,9 +325,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
|||||||
tray.slot_index = i;
|
tray.slot_index = i;
|
||||||
|
|
||||||
// Read slot variables
|
// Read slot variables
|
||||||
const int color_index = variables.value("color_slot" + std::to_string(i), 1);
|
const int color_index = read_int_or(variables, "color_slot" + std::to_string(i), 1);
|
||||||
const int filament_type = variables.value("filament_slot" + std::to_string(i), 1);
|
const int filament_type = read_int_or(variables, "filament_slot" + std::to_string(i), 1);
|
||||||
const int vendor_type = variables.value("vendor_slot" + std::to_string(i), 0);
|
const int vendor_type = read_int_or(variables, "vendor_slot" + std::to_string(i), 0);
|
||||||
|
|
||||||
// Check filament presence via runout sensor
|
// Check filament presence via runout sensor
|
||||||
std::string box_stepper_key = "box_stepper slot" + std::to_string(i);
|
std::string box_stepper_key = "box_stepper slot" + std::to_string(i);
|
||||||
@@ -175,7 +335,7 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
|||||||
if (status.contains(box_stepper_key)) {
|
if (status.contains(box_stepper_key)) {
|
||||||
auto& box_stepper = status[box_stepper_key];
|
auto& box_stepper = status[box_stepper_key];
|
||||||
if (box_stepper.contains("runout_button") && !box_stepper["runout_button"].is_null()) {
|
if (box_stepper.contains("runout_button") && !box_stepper["runout_button"].is_null()) {
|
||||||
int runout_button = box_stepper["runout_button"].template get<int>();
|
const int runout_button = read_int_or(box_stepper, "runout_button", 0);
|
||||||
tray.has_filament = (runout_button == 0);
|
tray.has_filament = (runout_button == 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,16 +349,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
|||||||
}
|
}
|
||||||
tray.tray_type = normalize_filament_type(filament_name);
|
tray.tray_type = normalize_filament_type(filament_name);
|
||||||
|
|
||||||
// Try Qidi-specific setting ID first; fall back to visible preset by type
|
// Qidi-specific setting id derived from device data only; the preset-dependent
|
||||||
std::string setting_id = build_setting_id(filament_type, vendor_type, tray.tray_type);
|
// fallback runs on the main thread in the resolve hook passed to build_ams_payload.
|
||||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
tray.tray_info_idx = build_setting_id(filament_type, vendor_type, tray.tray_type);
|
||||||
if (!bundle) {
|
|
||||||
tray.tray_info_idx = setting_id;
|
|
||||||
} else if (!setting_id.empty() && has_visible_base_preset(bundle->filaments, setting_id)) {
|
|
||||||
tray.tray_info_idx = setting_id;
|
|
||||||
} else {
|
|
||||||
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up color from dictionary
|
// Look up color from dictionary
|
||||||
auto color_it = dict.colors.find(color_index);
|
auto color_it = dict.colors.find(color_index);
|
||||||
@@ -215,20 +368,45 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
|
bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
|
||||||
const std::string& api_key,
|
nlohmann::json& status,
|
||||||
|
nlohmann::json& variables,
|
||||||
|
std::string& error)
|
||||||
|
{
|
||||||
|
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
||||||
|
if (json.is_discarded()) {
|
||||||
|
error = "Invalid JSON response";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") ||
|
||||||
|
!json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") ||
|
||||||
|
!json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") ||
|
||||||
|
!json["result"]["status"]["save_variables"]["variables"].is_object()) {
|
||||||
|
// why: Qidi firmware may send null here, but json::value() throws for it.
|
||||||
|
error = "Unexpected JSON structure: save_variables.variables must be an object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
status = json["result"]["status"];
|
||||||
|
variables = status["save_variables"]["variables"];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool QidiPrinterAgent::fetch_filament_dict(const ConnectionSettings& connection,
|
||||||
QidiFilamentDict& dict,
|
QidiFilamentDict& dict,
|
||||||
std::string& error) const
|
std::string& error) const
|
||||||
{
|
{
|
||||||
std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg");
|
std::string url = join_url(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
|
||||||
|
|
||||||
std::string response_body;
|
std::string response_body;
|
||||||
bool success = false;
|
bool success = false;
|
||||||
std::string http_error;
|
std::string http_error;
|
||||||
|
|
||||||
auto http = Http::get(url);
|
auto http = Http::get(url);
|
||||||
if (!api_key.empty()) {
|
configure_http(http, connection);
|
||||||
http.header("X-Api-Key", api_key);
|
if (!connection.api_key.empty()) {
|
||||||
|
http.header("X-Api-Key", connection.api_key);
|
||||||
}
|
}
|
||||||
http.timeout_connect(5)
|
http.timeout_connect(5)
|
||||||
.timeout_max(10)
|
.timeout_max(10)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
#include "MoonrakerPrinterAgent.hpp"
|
#include "MoonrakerPrinterAgent.hpp"
|
||||||
|
#include "bambu_networking.hpp"
|
||||||
|
#include "nlohmann/json_fwd.hpp"
|
||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -14,7 +16,7 @@ class QidiPrinterAgent final : public MoonrakerPrinterAgent
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit QidiPrinterAgent(std::string log_dir);
|
explicit QidiPrinterAgent(std::string log_dir);
|
||||||
~QidiPrinterAgent() override = default;
|
~QidiPrinterAgent() override { shutdown(); }
|
||||||
|
|
||||||
static AgentInfo get_agent_info_static();
|
static AgentInfo get_agent_info_static();
|
||||||
AgentInfo get_agent_info() override { return get_agent_info_static(); }
|
AgentInfo get_agent_info() override { return get_agent_info_static(); }
|
||||||
@@ -22,7 +24,22 @@ public:
|
|||||||
// Override filament sync (Qidi-specific implementation)
|
// Override filament sync (Qidi-specific implementation)
|
||||||
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
||||||
|
|
||||||
|
static bool parse_slot_response(const std::string& response_body,
|
||||||
|
nlohmann::json& status,
|
||||||
|
nlohmann::json& variables,
|
||||||
|
std::string& error);
|
||||||
|
|
||||||
|
// Print operations — emit QiDi multi-color box config, then delegate to base.
|
||||||
|
// Only the LAN print path is supported by the base; the cloud/record/sdcard variants
|
||||||
|
// are inherited and return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED.
|
||||||
|
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
|
||||||
|
|
||||||
|
FilamentSyncMode get_filament_sync_mode() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Push enable_box + value_t<tool> SAVE_VARIABLEs before a print starts.
|
||||||
|
// Returns false if any command fails (caller should abort the print).
|
||||||
|
bool apply_box_mapping(const PrintParams& params) const;
|
||||||
struct QidiFilamentDict
|
struct QidiFilamentDict
|
||||||
{
|
{
|
||||||
std::map<int, std::string> colors;
|
std::map<int, std::string> colors;
|
||||||
@@ -30,14 +47,13 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Qidi-specific methods
|
// Qidi-specific methods
|
||||||
bool fetch_slot_info(const std::string& base_url,
|
bool fetch_slot_info(const ConnectionSettings& connection,
|
||||||
const std::string& api_key,
|
|
||||||
const QidiFilamentDict& dict,
|
const QidiFilamentDict& dict,
|
||||||
const std::string& series_id,
|
const std::string& series_id,
|
||||||
std::vector<AmsTrayData>& trays,
|
std::vector<AmsTrayData>& trays,
|
||||||
int& box_count,
|
int& box_count,
|
||||||
std::string& error);
|
std::string& error);
|
||||||
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const;
|
bool fetch_filament_dict(const ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
|
||||||
std::string normalize_filament_type(const std::string& filament_type);
|
std::string normalize_filament_type(const std::string& filament_type);
|
||||||
std::string infer_series_id(const std::string& model_id, const std::string& dev_name);
|
std::string infer_series_id(const std::string& model_id, const std::string& dev_name);
|
||||||
std::string normalize_model_key(std::string value);
|
std::string normalize_model_key(std::string value);
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
#include "Http.hpp"
|
#include "Http.hpp"
|
||||||
#include "MoonrakerPrinterAgent.hpp"
|
#include "MoonrakerPrinterAgent.hpp"
|
||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
|
#include "bambu_networking.hpp"
|
||||||
#include "libslic3r/PresetBundle.hpp"
|
#include "libslic3r/PresetBundle.hpp"
|
||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
|
|
||||||
#include "nlohmann/json.hpp"
|
#include "nlohmann/json.hpp"
|
||||||
|
#include <atomic>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <exception>
|
||||||
|
#include <mutex>
|
||||||
|
#include <sstream>
|
||||||
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include "libslic3r/Preset.hpp"
|
#include "libslic3r/Preset.hpp"
|
||||||
@@ -18,6 +27,91 @@ namespace Slic3r {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1";
|
constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1";
|
||||||
|
constexpr int64_t CAMERA_REFRESH_INTERVAL_MS = 300'000;
|
||||||
|
|
||||||
|
int64_t now_ms()
|
||||||
|
{
|
||||||
|
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAII release of the in-flight fetch slot; movable so a failed thread start still releases it.
|
||||||
|
struct InFlightGuard
|
||||||
|
{
|
||||||
|
MoonrakerPrinterAgent* owner;
|
||||||
|
explicit InFlightGuard(MoonrakerPrinterAgent& o) noexcept : owner(&o) {}
|
||||||
|
InFlightGuard(InFlightGuard&& other) noexcept : owner(other.owner) { other.owner = nullptr; }
|
||||||
|
InFlightGuard(const InFlightGuard&) = delete;
|
||||||
|
InFlightGuard& operator=(const InFlightGuard&) = delete;
|
||||||
|
InFlightGuard& operator=(InFlightGuard&&) = delete;
|
||||||
|
~InFlightGuard() { if (owner) owner->release_fetch_slot(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// nlohmann::json::value() returns the default only when the key is absent; a present but
|
||||||
|
// null/wrong-typed value throws. Firmware JSON is untrusted, so read defensively.
|
||||||
|
int read_int_or(const nlohmann::json& obj, const char* key, int fallback)
|
||||||
|
{
|
||||||
|
auto it = obj.find(key);
|
||||||
|
if (it == obj.end()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (it->is_number_integer()) {
|
||||||
|
return it->get<int>();
|
||||||
|
}
|
||||||
|
// Firmware may report an integral field as a JSON float (e.g. 2.0). Accept it only when it
|
||||||
|
// has no fractional part, so a genuinely fractional value falls back instead of truncating.
|
||||||
|
if (it->is_number_float()) {
|
||||||
|
const double value = it->get<double>();
|
||||||
|
if (value == std::floor(value)) {
|
||||||
|
return static_cast<int>(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> read_string_array_or(const nlohmann::json& obj, const char* key)
|
||||||
|
{
|
||||||
|
auto it = obj.find(key);
|
||||||
|
if (it == obj.end() || !it->is_array())
|
||||||
|
return {};
|
||||||
|
std::vector<std::string> out;
|
||||||
|
out.reserve(it->size());
|
||||||
|
for (const auto& v : *it)
|
||||||
|
out.push_back(v.is_string() ? v.get<std::string>() : std::string{});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<bool> read_bool_array_or(const nlohmann::json& obj, const char* key)
|
||||||
|
{
|
||||||
|
auto it = obj.find(key);
|
||||||
|
if (it == obj.end() || !it->is_array())
|
||||||
|
return {};
|
||||||
|
std::vector<bool> out;
|
||||||
|
out.reserve(it->size());
|
||||||
|
for (const auto& v : *it)
|
||||||
|
out.push_back(v.is_boolean() ? v.get<bool>() : false);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a hex colour, stopping at the first non-hex character. std::stoul throws on
|
||||||
|
// empty/non-hex input, which must not escape the detached fetch thread.
|
||||||
|
unsigned int parse_hex_color(const std::string& hex)
|
||||||
|
{
|
||||||
|
unsigned int value = 0;
|
||||||
|
for (char c : hex) {
|
||||||
|
int digit;
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
digit = c - '0';
|
||||||
|
else if (c >= 'a' && c <= 'f')
|
||||||
|
digit = c - 'a' + 10;
|
||||||
|
else if (c >= 'A' && c <= 'F')
|
||||||
|
digit = c - 'A' + 10;
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
value = (value << 4) | static_cast<unsigned int>(digit);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
// Safely access a parallel array by index, returning a fallback if out of bounds.
|
// Safely access a parallel array by index, returning a fallback if out of bounds.
|
||||||
template<typename T>
|
template<typename T>
|
||||||
@@ -40,14 +134,16 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
|
|||||||
filaments.get_preset_base(p) == &p && p.config.opt_string("filament_vendor", 0u) == vendor_name &&
|
filaments.get_preset_base(p) == &p && p.config.opt_string("filament_vendor", 0u) == vendor_name &&
|
||||||
p.config.opt_string("filament_type", 0u) == filament_type) {
|
p.config.opt_string("filament_type", 0u) == filament_type) {
|
||||||
// The printer returns RGBA in the format RRGGBBAA, but profiles store color as #RRGGBB,
|
// The printer returns RGBA in the format RRGGBBAA, but profiles store color as #RRGGBB,
|
||||||
// so we must remove # and ignore alpha channel for distance calculation
|
// so we must remove # and ignore alpha channel for distance calculation. Firmware
|
||||||
unsigned int target_color_value = std::stoul(color_rgba.substr(0, color_rgba.length() - 2), nullptr, 16);
|
// colours are untrusted; parse_hex_color tolerates empty/non-hex instead of throwing.
|
||||||
|
unsigned int target_color_value =
|
||||||
|
parse_hex_color(color_rgba.substr(0, color_rgba.size() >= 2 ? color_rgba.size() - 2 : 0));
|
||||||
|
|
||||||
std::string p_color = p.config.opt_string("default_filament_colour", 0u);
|
std::string p_color = p.config.opt_string("default_filament_colour", 0u);
|
||||||
unsigned int p_color_value;
|
unsigned int p_color_value = 0;
|
||||||
if (!p_color.empty()) {
|
if (!p_color.empty()) {
|
||||||
size_t hash_pos = p_color.find("#");
|
size_t hash_pos = p_color.find("#");
|
||||||
p_color_value = std::stoul(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0), nullptr, 16);
|
p_color_value = parse_hex_color(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0));
|
||||||
} else {
|
} else {
|
||||||
// Default to black if no color specified in profile. Assume other profiles might be a closer color match.
|
// Default to black if no color specified in profile. Assume other profiles might be a closer color match.
|
||||||
// Could be a problem if the target color is also black and there exist a specific profile for that type, vendor and color
|
// Could be a problem if the target color is also black and there exist a specific profile for that type, vendor and color
|
||||||
@@ -74,6 +170,31 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
|
|||||||
|
|
||||||
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
|
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
|
||||||
|
|
||||||
|
void SnapmakerPrinterAgent::start_camera_monitor()
|
||||||
|
{
|
||||||
|
enqueue_command([this] {
|
||||||
|
send_ws_rpc("camera.start_monitor",
|
||||||
|
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
|
||||||
|
});
|
||||||
|
m_camera_last_fire_ms.store(now_ms());
|
||||||
|
}
|
||||||
|
|
||||||
|
void SnapmakerPrinterAgent::on_status_loop_tick(const std::string& dev_id)
|
||||||
|
{
|
||||||
|
(void) dev_id;
|
||||||
|
const int64_t last = m_camera_last_fire_ms.load();
|
||||||
|
if (last == 0 || now_ms() - last >= CAMERA_REFRESH_INTERVAL_MS) {
|
||||||
|
start_camera_monitor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
|
||||||
|
{
|
||||||
|
(void) dev_id;
|
||||||
|
start_camera_monitor();
|
||||||
|
return BAMBU_NETWORK_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
|
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
|
||||||
{
|
{
|
||||||
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
|
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
|
||||||
@@ -109,127 +230,192 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
|
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
|
||||||
{
|
{
|
||||||
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect");
|
(void) dev_id;
|
||||||
|
if (sync_mode != get_filament_sync_mode())
|
||||||
|
return false;
|
||||||
|
|
||||||
std::string response_body;
|
// Snapshot everything the fetch needs (URL, api key, TLS/CA): a reconnect can rewrite
|
||||||
bool success = false;
|
// device_info meanwhile.
|
||||||
std::string http_error;
|
const ConnectionSettings connection = get_connection_settings();
|
||||||
|
|
||||||
auto http = Http::get(url);
|
// One implementation for both modes; only where it runs differs. pull is the documented
|
||||||
if (!device_info.api_key.empty()) {
|
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
|
||||||
http.header("X-Api-Key", device_info.api_key);
|
// on the caller with the payload applied inline. subscription runs on a background thread
|
||||||
}
|
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
|
||||||
http.timeout_connect(5)
|
auto work = [this, connection](bool apply_inline) -> bool {
|
||||||
.timeout_max(10)
|
try {
|
||||||
.on_complete([&](std::string body, unsigned status) {
|
const std::string url = join_url(connection.base_url, "/printer/objects/query?print_task_config&filament_detect");
|
||||||
if (status == 200) {
|
|
||||||
response_body = body;
|
std::string response_body;
|
||||||
success = true;
|
bool success = false;
|
||||||
} else {
|
std::string http_error;
|
||||||
http_error = "HTTP error: " + std::to_string(status);
|
|
||||||
|
auto http = Http::get(url);
|
||||||
|
configure_http(http, connection);
|
||||||
|
if (!connection.api_key.empty()) {
|
||||||
|
http.header("X-Api-Key", connection.api_key);
|
||||||
}
|
}
|
||||||
})
|
http.timeout_connect(5)
|
||||||
.on_error([&](std::string body, std::string err, unsigned status) {
|
.timeout_max(10)
|
||||||
http_error = err;
|
.on_complete([&](std::string body, unsigned status) {
|
||||||
if (status > 0) {
|
if (status == 200) {
|
||||||
http_error += " (HTTP " + std::to_string(status) + ")";
|
response_body = body;
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
http_error = "HTTP error: " + std::to_string(status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_error([&](std::string body, std::string err, unsigned status) {
|
||||||
|
http_error = err;
|
||||||
|
if (status > 0) {
|
||||||
|
http_error += " (HTTP " + std::to_string(status) + ")";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.perform_sync();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.perform_sync();
|
|
||||||
|
|
||||||
if (!success) {
|
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
||||||
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
|
if (json.is_discarded()) {
|
||||||
return false;
|
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
// Navigate to result.status.print_task_config
|
||||||
if (json.is_discarded()) {
|
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) {
|
||||||
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
|
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to result.status.print_task_config
|
auto& ptc = json["result"]["status"]["print_task_config"];
|
||||||
if (!json.contains("result") || !json["result"].contains("status") ||
|
|
||||||
!json["result"]["status"].contains("print_task_config")) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& ptc = json["result"]["status"]["print_task_config"];
|
// Read parallel arrays from print_task_config
|
||||||
|
auto filament_exist = read_bool_array_or(ptc, "filament_exist");
|
||||||
|
auto filament_type = read_string_array_or(ptc, "filament_type");
|
||||||
|
auto filament_sub_type = read_string_array_or(ptc, "filament_sub_type");
|
||||||
|
auto filament_color = read_string_array_or(ptc, "filament_color_rgba");
|
||||||
|
auto filament_vendor = read_string_array_or(ptc, "filament_vendor");
|
||||||
|
|
||||||
// Read parallel arrays from print_task_config
|
const int slot_count = static_cast<int>(filament_exist.size());
|
||||||
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
|
if (slot_count == 0) {
|
||||||
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
|
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
|
||||||
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
|
return false;
|
||||||
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
|
}
|
||||||
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
|
|
||||||
|
|
||||||
const int slot_count = static_cast<int>(filament_exist.size());
|
// Read NFC filament_detect data for temperature info (optional)
|
||||||
if (slot_count == 0) {
|
nlohmann::json nfc_info;
|
||||||
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
|
if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) {
|
||||||
return false;
|
nfc_info = json["result"]["status"]["filament_detect"]["info"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read NFC filament_detect data for temperature info (optional)
|
static const std::string empty_str;
|
||||||
nlohmann::json nfc_info;
|
static const std::string default_color = "FFFFFFFF";
|
||||||
if (json["result"]["status"].contains("filament_detect") &&
|
|
||||||
json["result"]["status"]["filament_detect"].contains("info")) {
|
|
||||||
nfc_info = json["result"]["status"]["filament_detect"]["info"];
|
|
||||||
}
|
|
||||||
|
|
||||||
static const std::string empty_str;
|
// Per-tray vendor names, kept so the preset lookup can run on the main thread.
|
||||||
static const std::string default_color = "FFFFFFFF";
|
std::vector<std::string> vendors(slot_count);
|
||||||
|
|
||||||
std::vector<AmsTrayData> trays;
|
std::vector<AmsTrayData> trays;
|
||||||
trays.reserve(slot_count);
|
trays.reserve(slot_count);
|
||||||
|
|
||||||
for (int i = 0; i < slot_count; ++i) {
|
for (int i = 0; i < slot_count; ++i) {
|
||||||
AmsTrayData tray;
|
AmsTrayData tray;
|
||||||
tray.slot_index = i;
|
tray.slot_index = i;
|
||||||
tray.has_filament = filament_exist[i];
|
tray.has_filament = filament_exist[i];
|
||||||
|
|
||||||
if (tray.has_filament) {
|
if (tray.has_filament) {
|
||||||
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str),
|
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
|
||||||
safe_at(filament_sub_type, i, empty_str));
|
tray.tray_color = safe_at(filament_color, i, default_color);
|
||||||
tray.tray_color = safe_at(filament_color, i, default_color);
|
vendors[i] = safe_at(filament_vendor, i, empty_str);
|
||||||
|
|
||||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
// Generic id derived from device data only; the preset-dependent lookup runs
|
||||||
// Try to find a matching preset for this filament based on vendor, type and color.
|
// on the main thread in the resolve hook passed to build_ams_payload.
|
||||||
// If not found, default to traditional search by type only or generic type mapping.
|
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
|
||||||
if (bundle) {
|
|
||||||
std::string vendor = safe_at(filament_vendor, i, empty_str);
|
|
||||||
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
|
|
||||||
tray.tray_color);
|
|
||||||
|
|
||||||
if (!filament_id.empty()) {
|
// Extract NFC temperature data if available
|
||||||
tray.tray_info_idx = filament_id;
|
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << i << ": "
|
auto& nfc_slot = nfc_info[i];
|
||||||
<< filament_id;
|
std::string vendor = "NONE";
|
||||||
} else {
|
if (auto vendor_it = nfc_slot.find("VENDOR"); vendor_it != nfc_slot.end() && vendor_it->is_string()) {
|
||||||
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
vendor = vendor_it->get<std::string>();
|
||||||
|
}
|
||||||
|
if (vendor != "NONE" && !vendor.empty()) {
|
||||||
|
tray.bed_temp = read_int_or(nfc_slot, "BED_TEMP", 0);
|
||||||
|
tray.nozzle_temp = read_int_or(nfc_slot, "FIRST_LAYER_TEMP", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
|
trays.emplace_back(std::move(tray));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract NFC temperature data if available
|
// Resolve against GUI-owned presets on the main thread (inside build_ams_payload).
|
||||||
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
auto resolve_ids = [vendors](std::vector<AmsTrayData>& resolved_trays) {
|
||||||
auto& nfc_slot = nfc_info[i];
|
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||||
std::string vendor = nfc_slot.value("VENDOR", "NONE");
|
if (!bundle) {
|
||||||
if (vendor != "NONE" && !vendor.empty()) {
|
return; // keep the generic ids computed from device data
|
||||||
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
|
|
||||||
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
|
|
||||||
}
|
}
|
||||||
}
|
for (size_t i = 0; i < resolved_trays.size() && i < vendors.size(); ++i) {
|
||||||
|
AmsTrayData& tray = resolved_trays[i];
|
||||||
|
if (!tray.has_filament) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Prefer a manufacturer/colour-specific preset, else fall back to type.
|
||||||
|
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendors[i], tray.tray_type,
|
||||||
|
tray.tray_color);
|
||||||
|
tray.tray_info_idx = filament_id.empty() ? bundle->filaments.filament_id_by_type(tray.tray_type) : filament_id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
build_ams_payload(1, slot_count - 1, trays, apply_inline, resolve_ids);
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
// why: an exception escaping a detached thread is std::terminate, and firmware
|
||||||
|
// JSON is untrusted; mirror run_command_worker and swallow it here.
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
|
||||||
|
return false;
|
||||||
|
} catch (...) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
trays.emplace_back(std::move(tray));
|
if (sync_mode == FilamentSyncMode::pull) {
|
||||||
|
return work(/*apply_inline=*/true);
|
||||||
}
|
}
|
||||||
|
|
||||||
build_ams_payload(1, slot_count - 1, trays);
|
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
|
||||||
|
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
|
||||||
|
// shutdown() uses, so the flag and the count can't race.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
|
||||||
|
if (shutting_down.load())
|
||||||
|
return false;
|
||||||
|
if (filament_fetch_in_flight.load() > 0)
|
||||||
|
return true; // a fetch is already running; don't pile on
|
||||||
|
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
InFlightGuard guard{*this};
|
||||||
|
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
|
||||||
|
work(/*apply_inline=*/false);
|
||||||
|
}).detach();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string SnapmakerPrinterAgent::get_camera_url() const
|
||||||
|
{
|
||||||
|
return join_url(get_connection_settings().base_url, "/server/files/camera/monitor.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
|
||||||
|
{
|
||||||
|
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
|
||||||
|
return FilamentSyncMode::subscription;
|
||||||
|
return FilamentSyncMode::pull;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
#include "MoonrakerPrinterAgent.hpp"
|
#include "MoonrakerPrinterAgent.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
@@ -11,16 +13,25 @@ class SnapmakerPrinterAgent final : public MoonrakerPrinterAgent
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit SnapmakerPrinterAgent(std::string log_dir);
|
explicit SnapmakerPrinterAgent(std::string log_dir);
|
||||||
~SnapmakerPrinterAgent() override = default;
|
~SnapmakerPrinterAgent() override { shutdown(); }
|
||||||
|
|
||||||
static AgentInfo get_agent_info_static();
|
static AgentInfo get_agent_info_static();
|
||||||
AgentInfo get_agent_info() override { return get_agent_info_static(); }
|
AgentInfo get_agent_info() override { return get_agent_info_static(); }
|
||||||
|
|
||||||
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
||||||
|
FilamentSyncMode get_filament_sync_mode() const override;
|
||||||
|
int command_start_camera(std::string dev_id) override;
|
||||||
|
CameraStreamMode get_camera_stream_mode() const override { return CameraStreamMode::http_snapshot; }
|
||||||
|
std::string get_camera_url() const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Combine filament_type + filament_sub_type into a unified type string
|
// Combine filament_type + filament_sub_type into a unified type string
|
||||||
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
|
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
|
||||||
|
|
||||||
|
void start_camera_monitor();
|
||||||
|
void on_status_loop_tick(const std::string& dev_id) override;
|
||||||
|
|
||||||
|
std::atomic<int64_t> m_camera_last_fire_ms{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
#include "PluginAuditManagerInternal.hpp"
|
|
||||||
|
|
||||||
#include "../Utils/OrcaCloudServiceAgent.hpp"
|
#include "../Utils/OrcaCloudServiceAgent.hpp"
|
||||||
#include "libslic3r/Utils.hpp"
|
#include "libslic3r/Utils.hpp"
|
||||||
@@ -16,12 +15,25 @@
|
|||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <future>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <memory>
|
||||||
|
#include <slic3r/GUI/BindDialog.hpp>
|
||||||
|
#include <slic3r/GUI/GUI_App.hpp>
|
||||||
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||||
|
#include <slic3r/plugin/PluginManager.hpp>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <wx/app.h>
|
||||||
|
#include <wx/event.h>
|
||||||
|
#include <wx/msgdlg.h>
|
||||||
|
#include <wx/string.h>
|
||||||
|
#include <wx/thread.h>
|
||||||
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
|
#include "slic3r/plugin/PluginDescriptor.hpp"
|
||||||
#include <Python.h>
|
#include <Python.h>
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
@@ -149,10 +161,8 @@ static bool is_fs_category(AuditEventCategory category)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace plugin_audit {
|
|
||||||
|
|
||||||
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
|
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
|
||||||
const char* category_name(AuditEventCategory category)
|
static const char* category_name(AuditEventCategory category)
|
||||||
{
|
{
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case AuditEventCategory::FsRead: return "FsRead";
|
case AuditEventCategory::FsRead: return "FsRead";
|
||||||
@@ -169,29 +179,6 @@ const char* category_name(AuditEventCategory category)
|
|||||||
return "None";
|
return "None";
|
||||||
}
|
}
|
||||||
|
|
||||||
int report_denied(PluginAuditManager& mgr,
|
|
||||||
const std::string& event_name,
|
|
||||||
const AuditDecision& decision,
|
|
||||||
const std::string& target)
|
|
||||||
{
|
|
||||||
AuditViolation violation;
|
|
||||||
violation.plugin_key = mgr.current_plugin();
|
|
||||||
violation.event_name = event_name;
|
|
||||||
violation.reason = decision.reason;
|
|
||||||
mgr.report_violation(violation);
|
|
||||||
|
|
||||||
std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission";
|
|
||||||
if (!decision.reason.empty())
|
|
||||||
message += ": " + decision.reason;
|
|
||||||
if (!target.empty())
|
|
||||||
message += ": " + target;
|
|
||||||
|
|
||||||
PyErr_SetString(PyExc_PermissionError, message.c_str());
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace plugin_audit
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Path safety
|
// Path safety
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -260,7 +247,6 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
|
|||||||
|
|
||||||
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
||||||
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
||||||
thread_local std::string PluginAuditManager::m_current_audit_phase = "";
|
|
||||||
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
||||||
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
|
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
|
||||||
thread_local int PluginAuditManager::m_releasing_frames = 0;
|
thread_local int PluginAuditManager::m_releasing_frames = 0;
|
||||||
@@ -330,76 +316,6 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca
|
|||||||
|
|
||||||
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
|
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
|
||||||
|
|
||||||
void PluginAuditManager::set_current_audit_phase(const std::string& phase) { m_current_audit_phase = phase; }
|
|
||||||
|
|
||||||
const std::string& PluginAuditManager::current_audit_phase() const { return m_current_audit_phase; }
|
|
||||||
|
|
||||||
void PluginAuditManager::clear_current_audit_phase() { m_current_audit_phase.clear(); }
|
|
||||||
|
|
||||||
void PluginAuditManager::set_observation_observer(AuditObserver observer)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
|
||||||
m_observation_observer = std::move(observer);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PluginAuditManager::clear_observation_observer()
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
|
||||||
m_observation_observer = nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PluginAuditManager::observation_mode() const
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
|
||||||
return static_cast<bool>(m_observation_observer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The audit hook holds the GIL here. Copy the observer out before invoking it so a callback that
|
|
||||||
// reads the manager cannot deadlock on m_observer_mutex.
|
|
||||||
bool PluginAuditManager::record_observation(const std::string& event_name,
|
|
||||||
const std::string& category,
|
|
||||||
const std::vector<std::string>& targets)
|
|
||||||
{
|
|
||||||
AuditObserver observer;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_observer_mutex);
|
|
||||||
observer = m_observation_observer;
|
|
||||||
}
|
|
||||||
if (!observer)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// The observer is called from a CPython C callback; a C++ exception must not unwind through
|
|
||||||
// the audit hook. The probe's collector only allocates, so a throw here is a lost observation,
|
|
||||||
// not a reason to abort the audited operation.
|
|
||||||
try {
|
|
||||||
AuditObservation observation;
|
|
||||||
observation.plugin_key = m_current_plugin_key;
|
|
||||||
observation.capability = m_current_capability_name;
|
|
||||||
observation.phase = m_current_audit_phase;
|
|
||||||
observation.event_name = event_name;
|
|
||||||
observation.category = category;
|
|
||||||
observation.targets = targets;
|
|
||||||
// Probe mode never denies: the hook returns 0 after recording. A later failure of the
|
|
||||||
// surrounding Python operation is reported by the probe's callback/load result, not here.
|
|
||||||
observation.continued = true;
|
|
||||||
observer(observation);
|
|
||||||
} catch (...) {
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ScopedPluginAuditPhase::ScopedPluginAuditPhase(std::string phase)
|
|
||||||
: m_phase(std::move(phase))
|
|
||||||
, m_previous_phase(PluginAuditManager::instance().current_audit_phase())
|
|
||||||
{
|
|
||||||
PluginAuditManager::instance().set_current_audit_phase(m_phase);
|
|
||||||
}
|
|
||||||
|
|
||||||
ScopedPluginAuditPhase::~ScopedPluginAuditPhase()
|
|
||||||
{
|
|
||||||
PluginAuditManager::instance().set_current_audit_phase(m_previous_phase);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write)
|
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write)
|
||||||
{
|
{
|
||||||
if (root.empty())
|
if (root.empty())
|
||||||
@@ -437,18 +353,16 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
|
|||||||
std::vector<std::string> PluginAuditManager::default_denied_filenames()
|
std::vector<std::string> PluginAuditManager::default_denied_filenames()
|
||||||
{
|
{
|
||||||
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
|
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
|
||||||
// on its mode, and is a non-static member we cannot call without an instance. Denying all
|
// on its mode, and is a non-static member we cannot call without an instance. Denying all
|
||||||
// four names is cheaper and more robust than replicating that; the two unused names cost one
|
// four names is cheaper and more robust than replicating that; the two unused names cost one
|
||||||
// string comparison each. Keep the plugin install-state sidecar here too: it controls which
|
// string comparison each. Single-sourced here so install_hook() and the tests seed from the
|
||||||
// packages load on startup and must remain host-managed. Single-sourced so install_hook() and
|
// same list and cannot drift apart.
|
||||||
// the tests seed from the same list and cannot drift apart.
|
|
||||||
return {
|
return {
|
||||||
SLIC3R_APP_KEY ".conf",
|
SLIC3R_APP_KEY ".conf",
|
||||||
GCODEVIEWER_APP_KEY ".conf",
|
GCODEVIEWER_APP_KEY ".conf",
|
||||||
SLIC3R_APP_KEY ".ini",
|
SLIC3R_APP_KEY ".ini",
|
||||||
GCODEVIEWER_APP_KEY ".ini",
|
GCODEVIEWER_APP_KEY ".ini",
|
||||||
secret_constants::USER_SECRET_FILENAME,
|
secret_constants::USER_SECRET_FILENAME,
|
||||||
".install_state.json",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,14 +534,73 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
|
|||||||
if (plugin_key.empty() || paths.empty())
|
if (plugin_key.empty() || paths.empty())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// Probe mode records the declaration and proceeds. It must not consult the install state,
|
PluginDescriptor descriptor;
|
||||||
// open the permission dialog, or persist a grant.
|
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||||
if (observation_mode()) {
|
return false;
|
||||||
record_observation("orca.request_permissions", "FsRead", paths);
|
|
||||||
|
PluginInstallState state;
|
||||||
|
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
|
|
||||||
|
std::vector<std::string> missing;
|
||||||
|
for (const std::string& path : paths) {
|
||||||
|
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
|
||||||
|
missing.push_back(path);
|
||||||
|
}
|
||||||
|
if (missing.empty())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
auto show_dialog = [&descriptor, &missing]() {
|
||||||
|
wxString requested_paths;
|
||||||
|
for (const std::string& path : missing)
|
||||||
|
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||||
|
|
||||||
|
wxMessageDialog dialog(
|
||||||
|
nullptr,
|
||||||
|
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||||
|
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
|
||||||
|
_L("Plugin permissions"),
|
||||||
|
wxYES_NO | wxICON_WARNING);
|
||||||
|
return dialog.ShowModal() == wxID_YES;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool granted = false;
|
||||||
|
if (wxIsMainThread()) {
|
||||||
|
granted = show_dialog();
|
||||||
|
} else {
|
||||||
|
auto result = std::make_shared<std::promise<bool>>();
|
||||||
|
auto future = result->get_future();
|
||||||
|
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
|
||||||
|
wxString requested_paths;
|
||||||
|
for (const std::string& path : missing)
|
||||||
|
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||||
|
|
||||||
|
wxMessageDialog dialog(
|
||||||
|
nullptr,
|
||||||
|
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||||
|
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
|
||||||
|
_L("Plugin permissions"),
|
||||||
|
wxYES_NO | wxICON_WARNING);
|
||||||
|
result->set_value(dialog.ShowModal() == wxID_YES);
|
||||||
|
});
|
||||||
|
granted = future.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugin_audit::request_filesystem_read_permissions(plugin_key, paths);
|
if (!granted)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (state.plugin_name.empty()) {
|
||||||
|
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||||
|
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||||
|
state.plugin_name = descriptor.name;
|
||||||
|
state.cloud_uuid = descriptor.cloud_uuid();
|
||||||
|
state.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
||||||
|
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
||||||
@@ -889,18 +862,239 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The saved list that grants an event: its category's target list, or the event-grant list for
|
||||||
|
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
|
||||||
|
// os.fork grant is not an os.exec of a command named "os.fork").
|
||||||
|
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
|
||||||
|
{
|
||||||
|
if (by_event)
|
||||||
|
return &permissions.events;
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
||||||
|
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
||||||
|
case AuditEventCategory::Http: return &permissions.network_http;
|
||||||
|
case AuditEventCategory::Socket: return &permissions.network_socket;
|
||||||
|
case AuditEventCategory::ProcessCreate: return &permissions.process;
|
||||||
|
case AuditEventCategory::Threading: return &permissions.threading;
|
||||||
|
case AuditEventCategory::ProcessReplace: return &permissions.process;
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
|
||||||
|
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
|
||||||
|
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
|
||||||
|
{
|
||||||
|
return std::string(category_name(category)) + ":" + event_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
|
||||||
|
{
|
||||||
|
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool persist_permission(const std::string& plugin_key,
|
||||||
|
PluginInstallState& state,
|
||||||
|
std::vector<std::string>& permission_list,
|
||||||
|
const std::string& target)
|
||||||
|
{
|
||||||
|
PluginDescriptor descriptor;
|
||||||
|
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
|
||||||
|
// needed by the writer so clicking Yes still creates the JSON file.
|
||||||
|
if (state.plugin_name.empty()) {
|
||||||
|
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
||||||
|
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
||||||
|
state.plugin_name = descriptor.name;
|
||||||
|
state.cloud_uuid = descriptor.cloud_uuid();
|
||||||
|
state.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!has_permission(permission_list, target))
|
||||||
|
permission_list.push_back(target);
|
||||||
|
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
|
}
|
||||||
|
|
||||||
|
int report_denied(PluginAuditManager& mgr,
|
||||||
|
const std::string& event_name,
|
||||||
|
const AuditDecision& decision,
|
||||||
|
const std::string& target = {})
|
||||||
|
{
|
||||||
|
AuditViolation violation;
|
||||||
|
violation.plugin_key = mgr.current_plugin();
|
||||||
|
violation.event_name = event_name;
|
||||||
|
violation.reason = decision.reason;
|
||||||
|
mgr.report_violation(violation);
|
||||||
|
|
||||||
|
std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission";
|
||||||
|
if (!decision.reason.empty())
|
||||||
|
message += ": " + decision.reason;
|
||||||
|
if (!target.empty())
|
||||||
|
message += ": " + target;
|
||||||
|
|
||||||
|
PyErr_SetString(PyExc_PermissionError, message.c_str());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
|
||||||
|
const wxString& target_list, size_t target_count)
|
||||||
|
{
|
||||||
|
if (target_list.IsEmpty())
|
||||||
|
return wxString::Format(
|
||||||
|
_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n"
|
||||||
|
"This operation does not expose a target the audit hook can display."),
|
||||||
|
plugin_name, event_name);
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case AuditEventCategory::FsRead:
|
||||||
|
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s",
|
||||||
|
"Plugin \"%s\" is requesting to read the following files:\n%s", target_count),
|
||||||
|
plugin_name, target_list);
|
||||||
|
case AuditEventCategory::FsReadWrite:
|
||||||
|
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s",
|
||||||
|
"Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count),
|
||||||
|
plugin_name, target_list);
|
||||||
|
case AuditEventCategory::FsCreate:
|
||||||
|
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s",
|
||||||
|
"Plugin \"%s\" is requesting to create the following files:\n%s", target_count),
|
||||||
|
plugin_name, target_list);
|
||||||
|
case AuditEventCategory::FsDelete:
|
||||||
|
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s",
|
||||||
|
"Plugin \"%s\" is requesting to delete the following files:\n%s", target_count),
|
||||||
|
plugin_name, target_list);
|
||||||
|
case AuditEventCategory::Http:
|
||||||
|
return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list);
|
||||||
|
case AuditEventCategory::Socket:
|
||||||
|
return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list);
|
||||||
|
case AuditEventCategory::ProcessCreate:
|
||||||
|
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s",
|
||||||
|
"Plugin \"%s\" is requesting to run the following commands:\n%s", target_count),
|
||||||
|
plugin_name, target_list);
|
||||||
|
case AuditEventCategory::Threading:
|
||||||
|
return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name);
|
||||||
|
case AuditEventCategory::ProcessReplace:
|
||||||
|
return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\n%s"), plugin_name, target_list);
|
||||||
|
default:
|
||||||
|
return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds and shows the modal permission prompt. Must run on the GUI thread.
|
||||||
|
bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name,
|
||||||
|
const std::vector<std::string>& unresolved)
|
||||||
|
{
|
||||||
|
wxString target_list;
|
||||||
|
for (const auto& target : unresolved)
|
||||||
|
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
||||||
|
|
||||||
|
wxMessageDialog dialog(nullptr,
|
||||||
|
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
||||||
|
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
||||||
|
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
||||||
|
return dialog.ShowModal() == wxID_YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
||||||
|
// freshly because the async prompt outlives the caller's stack copy of it.
|
||||||
|
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
|
||||||
|
{
|
||||||
|
PluginInstallState state;
|
||||||
|
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||||
|
if (!permission_list)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const auto& key : keys)
|
||||||
|
persist_permission(plugin_key, state, *permission_list, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
||||||
|
|
||||||
|
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
|
||||||
|
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
|
||||||
|
// no target (by_event), which the prompt does not show as a target.
|
||||||
|
//
|
||||||
|
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
|
||||||
|
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||||
|
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
||||||
|
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
||||||
|
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
|
||||||
|
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
||||||
|
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
||||||
|
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
||||||
|
// re-prompting.
|
||||||
|
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
|
||||||
|
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
|
||||||
|
{
|
||||||
|
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
|
||||||
|
|
||||||
|
if (s_permission_prompt_for_testing)
|
||||||
|
return s_permission_prompt_for_testing(event_name, shown);
|
||||||
|
|
||||||
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (wxIsMainThread())
|
||||||
|
return prompt_for_targets(category, plugin_name, event_name, shown);
|
||||||
|
|
||||||
|
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
||||||
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||||
|
return;
|
||||||
|
if (prompt_for_targets(category, plugin_name, event_name, shown))
|
||||||
|
persist_grant(plugin_key, category, by_event, keys);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
|
||||||
|
// otherwise the user is asked and a Yes is saved.
|
||||||
|
int decide_audited_event(PluginAuditManager& mgr,
|
||||||
|
PluginInstallState& state,
|
||||||
|
const std::string& plugin_key,
|
||||||
|
const std::string& plugin_name,
|
||||||
|
const std::string& event_name,
|
||||||
|
AuditEventCategory category,
|
||||||
|
const std::vector<std::string>& targets)
|
||||||
|
{
|
||||||
|
const bool by_event = targets.empty();
|
||||||
|
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
|
||||||
|
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
||||||
|
|
||||||
|
std::vector<std::string> unresolved = keys;
|
||||||
|
if (permission_list) {
|
||||||
|
unresolved.clear();
|
||||||
|
for (const auto& key : keys)
|
||||||
|
if (!has_permission(*permission_list, key))
|
||||||
|
unresolved.push_back(key);
|
||||||
|
if (unresolved.empty())
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
|
||||||
|
// where no dialog may open: what they do must already be granted.
|
||||||
|
if (mgr.releasing_frames())
|
||||||
|
return report_denied(mgr, event_name, {false, "audit permission required"});
|
||||||
|
|
||||||
|
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
|
||||||
|
return report_denied(mgr, event_name, {false, "audit permission required"});
|
||||||
|
|
||||||
|
if (permission_list)
|
||||||
|
for (const auto& key : unresolved)
|
||||||
|
persist_permission(plugin_key, state, *permission_list, key);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Judges an audited event raised while a plugin context is active.
|
// Judges an audited event raised while a plugin context is active.
|
||||||
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
|
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
|
||||||
{
|
{
|
||||||
const bool fs_category = is_fs_category(event_type);
|
const bool fs_category = is_fs_category(event_type);
|
||||||
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
|
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
|
||||||
|
|
||||||
// Probe mode: report the classified event and let it through. Deliberately before the deny
|
|
||||||
// registry and allowed-root checks, so the report covers operations that production would
|
|
||||||
// block as well as those it would allow. No grant is read or written.
|
|
||||||
if (mgr.record_observation(event_name, plugin_audit::category_name(event_type), targets))
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
||||||
// unconditional block: checked before the running-operation check below, so no granted
|
// unconditional block: checked before the running-operation check below, so no granted
|
||||||
// operation can launder access to one, and before the allowed-root shortcut further down,
|
// operation can launder access to one, and before the allowed-root shortcut further down,
|
||||||
@@ -909,7 +1103,7 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
|
|||||||
if (fs_category) {
|
if (fs_category) {
|
||||||
for (const auto& target : targets) {
|
for (const auto& target : targets) {
|
||||||
if (mgr.is_denied_path(boost::filesystem::path(target)))
|
if (mgr.is_denied_path(boost::filesystem::path(target)))
|
||||||
return plugin_audit::report_denied(mgr, event_name, {false, "denied path"}, target);
|
return report_denied(mgr, event_name, {false, "denied path"}, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,7 +1128,15 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int result = plugin_audit::decide_event(mgr, event_name, event_type, targets);
|
PluginInstallState state;
|
||||||
|
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
|
||||||
|
if (!have_install_state) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
|
||||||
|
|
||||||
|
const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets);
|
||||||
|
|
||||||
// A granted operation covers its nested events while it runs. A targetless grant names no
|
// A granted operation covers its nested events while it runs. A targetless grant names no
|
||||||
// operation, so it covers nothing else.
|
// operation, so it covers nothing else.
|
||||||
@@ -1021,6 +1223,11 @@ void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
|
|||||||
PyErr_SetRaisedException(error);
|
PyErr_SetRaisedException(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
|
||||||
|
{
|
||||||
|
PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||||||
{
|
{
|
||||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||||||
@@ -1076,9 +1283,8 @@ void PluginAuditManager::install_hook()
|
|||||||
// "cert" denied-path keyword seeded below.
|
// "cert" denied-path keyword seeded below.
|
||||||
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
|
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
|
||||||
|
|
||||||
// The user's app config and cloud credentials live directly inside data_dir(), and plugin
|
// The user's app config and cloud credentials live directly inside data_dir(), so the
|
||||||
// install state lives beneath it, so the root just granted would otherwise expose them to
|
// root just granted would otherwise expose them to any plugin. Deny them by name.
|
||||||
// plugins. Deny them by name.
|
|
||||||
//
|
//
|
||||||
// Seeded here rather than by each secret's owner because install_hook() runs during lazy
|
// Seeded here rather than by each secret's owner because install_hook() runs during lazy
|
||||||
// interpreter init and therefore provably precedes any plugin bytecode, whereas
|
// interpreter init and therefore provably precedes any plugin bytecode, whereas
|
||||||
|
|||||||
@@ -24,19 +24,6 @@ struct AuditViolation {
|
|||||||
std::string reason;
|
std::string reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
// One classified CPython audit event observed while the probe is in record-only mode. This is
|
|
||||||
// evidence about the code path that ran, not a grant request: the hook allowed the operation to
|
|
||||||
// continue and did not consult the permission dialog or the persisted install state.
|
|
||||||
struct AuditObservation {
|
|
||||||
std::string plugin_key;
|
|
||||||
std::string capability;
|
|
||||||
std::string phase;
|
|
||||||
std::string event_name;
|
|
||||||
std::string category;
|
|
||||||
std::vector<std::string> targets;
|
|
||||||
bool continued = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A filesystem root a plugin may access while an audit context is active. allow_write is
|
// A filesystem root a plugin may access while an audit context is active. allow_write is
|
||||||
// false for a root that only grants reads (e.g. the bundled, shared resources folder) --
|
// false for a root that only grants reads (e.g. the bundled, shared resources folder) --
|
||||||
// a write-shaped event never matches such a root, even though a read-shaped one does.
|
// a write-shaped event never matches such a root, even though a read-shaped one does.
|
||||||
@@ -150,30 +137,6 @@ public:
|
|||||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||||
const std::vector<std::string>& paths);
|
const std::vector<std::string>& paths);
|
||||||
|
|
||||||
// --- probe observation mode (thread-safe, never used by the GUI) ---
|
|
||||||
// When an observer is installed, every classified event raised by plugin-authored code is
|
|
||||||
// reported to it and then allowed through (the hook returns 0). The permission dialogs, saved
|
|
||||||
// grants and .install_state.json are never consulted, so this is not a containment boundary.
|
|
||||||
// The observer must not touch Python objects: it runs inside the audit hook with the GIL held.
|
|
||||||
using AuditObserver = std::function<void(const AuditObservation&)>;
|
|
||||||
void set_observation_observer(AuditObserver observer);
|
|
||||||
void clear_observation_observer();
|
|
||||||
bool observation_mode() const;
|
|
||||||
|
|
||||||
// Stop after classification/target extraction; true means an observer consumed the event.
|
|
||||||
// Exposed for the probe and its tests, not for production callers.
|
|
||||||
bool record_observation(const std::string& event_name,
|
|
||||||
const std::string& category,
|
|
||||||
const std::vector<std::string>& targets);
|
|
||||||
|
|
||||||
// --- probe phase attribution (thread_local) ---
|
|
||||||
// Labels events for the probe's report. Production leaves it empty. A Python thread spawned by
|
|
||||||
// plugin code inherits no phase, so its events are reported as unattributed rather than being
|
|
||||||
// guessed at.
|
|
||||||
void set_current_audit_phase(const std::string& phase);
|
|
||||||
const std::string& current_audit_phase() const;
|
|
||||||
void clear_current_audit_phase();
|
|
||||||
|
|
||||||
void report_violation(const AuditViolation& violation);
|
void report_violation(const AuditViolation& violation);
|
||||||
bool audit_denial_pending() const;
|
bool audit_denial_pending() const;
|
||||||
void clear_audit_denial();
|
void clear_audit_denial();
|
||||||
@@ -215,7 +178,6 @@ private:
|
|||||||
|
|
||||||
static thread_local std::string m_current_plugin_key;
|
static thread_local std::string m_current_plugin_key;
|
||||||
static thread_local std::string m_current_capability_name;
|
static thread_local std::string m_current_capability_name;
|
||||||
static thread_local std::string m_current_audit_phase;
|
|
||||||
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||||
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
static thread_local std::vector<PyObject*> m_root_frames; // strong references
|
||||||
static thread_local int m_releasing_frames;
|
static thread_local int m_releasing_frames;
|
||||||
@@ -228,11 +190,6 @@ private:
|
|||||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||||
std::vector<std::string> m_denied_filenames;
|
std::vector<std::string> m_denied_filenames;
|
||||||
std::vector<std::string> m_denied_path_keywords;
|
std::vector<std::string> m_denied_path_keywords;
|
||||||
|
|
||||||
// Kept apart from m_mutex: observation_mode() is queried from the audit hook while the hook
|
|
||||||
// already holds the GIL, and the setter runs before plugin code starts.
|
|
||||||
mutable std::mutex m_observer_mutex;
|
|
||||||
AuditObserver m_observation_observer;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||||
@@ -263,22 +220,6 @@ private:
|
|||||||
std::vector<PyObject*> m_previous_root_frames;
|
std::vector<PyObject*> m_previous_root_frames;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sets the probe's phase label on the current thread and restores the previous one on scope exit.
|
|
||||||
// Empty in production, where no phase is ever set.
|
|
||||||
class ScopedPluginAuditPhase
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit ScopedPluginAuditPhase(std::string phase);
|
|
||||||
~ScopedPluginAuditPhase();
|
|
||||||
|
|
||||||
ScopedPluginAuditPhase(const ScopedPluginAuditPhase&) = delete;
|
|
||||||
ScopedPluginAuditPhase& operator=(const ScopedPluginAuditPhase&) = delete;
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::string m_phase;
|
|
||||||
std::string m_previous_phase;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|
||||||
#endif // slic3r_PluginAuditManager_hpp_
|
#endif // slic3r_PluginAuditManager_hpp_
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
#include "PluginAuditManagerInternal.hpp"
|
|
||||||
|
|
||||||
#include "PluginDescriptor.hpp"
|
|
||||||
#include "PluginFsUtils.hpp"
|
|
||||||
#include "PluginManager.hpp"
|
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
|
||||||
#include <boost/filesystem/path.hpp>
|
|
||||||
#include <boost/log/trivial.hpp>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cstddef>
|
|
||||||
#include <future>
|
|
||||||
#include <memory>
|
|
||||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
|
||||||
#include <string>
|
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <slic3r/GUI/GUI_App.hpp>
|
|
||||||
#include <slic3r/GUI/I18N.hpp>
|
|
||||||
|
|
||||||
#include <wx/app.h>
|
|
||||||
#include <wx/msgdlg.h>
|
|
||||||
#include <wx/string.h>
|
|
||||||
#include <wx/thread.h>
|
|
||||||
|
|
||||||
namespace Slic3r {
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// The saved list that grants an event: its category's target list, or the event-grant list for
|
|
||||||
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
|
|
||||||
// os.fork grant is not an os.exec of a command named "os.fork").
|
|
||||||
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
|
|
||||||
{
|
|
||||||
if (by_event)
|
|
||||||
return &permissions.events;
|
|
||||||
|
|
||||||
switch (category) {
|
|
||||||
case AuditEventCategory::FsRead: return &permissions.fs_read;
|
|
||||||
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
|
|
||||||
case AuditEventCategory::Http: return &permissions.network_http;
|
|
||||||
case AuditEventCategory::Socket: return &permissions.network_socket;
|
|
||||||
case AuditEventCategory::ProcessCreate: return &permissions.process;
|
|
||||||
case AuditEventCategory::Threading: return &permissions.threading;
|
|
||||||
case AuditEventCategory::ProcessReplace: return &permissions.process;
|
|
||||||
default: return nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
|
|
||||||
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
|
|
||||||
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
|
|
||||||
{
|
|
||||||
return std::string(plugin_audit::category_name(category)) + ":" + event_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
|
|
||||||
{
|
|
||||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool persist_permission(const std::string& plugin_key,
|
|
||||||
PluginInstallState& state,
|
|
||||||
std::vector<std::string>& permission_list,
|
|
||||||
const std::string& target)
|
|
||||||
{
|
|
||||||
PluginDescriptor descriptor;
|
|
||||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
|
|
||||||
// needed by the writer so clicking Yes still creates the JSON file.
|
|
||||||
if (state.plugin_name.empty()) {
|
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
|
||||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
|
||||||
state.plugin_name = descriptor.name;
|
|
||||||
state.cloud_uuid = descriptor.cloud_uuid();
|
|
||||||
state.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!has_permission(permission_list, target))
|
|
||||||
permission_list.push_back(target);
|
|
||||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
|
||||||
}
|
|
||||||
|
|
||||||
wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
|
|
||||||
const wxString& target_list, size_t target_count)
|
|
||||||
{
|
|
||||||
if (target_list.IsEmpty())
|
|
||||||
return wxString::Format(
|
|
||||||
_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n"
|
|
||||||
"This operation does not expose a target the audit hook can display."),
|
|
||||||
plugin_name, event_name);
|
|
||||||
|
|
||||||
switch (category) {
|
|
||||||
case AuditEventCategory::FsRead:
|
|
||||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s",
|
|
||||||
"Plugin \"%s\" is requesting to read the following files:\n%s", target_count),
|
|
||||||
plugin_name, target_list);
|
|
||||||
case AuditEventCategory::FsReadWrite:
|
|
||||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s",
|
|
||||||
"Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count),
|
|
||||||
plugin_name, target_list);
|
|
||||||
case AuditEventCategory::FsCreate:
|
|
||||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s",
|
|
||||||
"Plugin \"%s\" is requesting to create the following files:\n%s", target_count),
|
|
||||||
plugin_name, target_list);
|
|
||||||
case AuditEventCategory::FsDelete:
|
|
||||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s",
|
|
||||||
"Plugin \"%s\" is requesting to delete the following files:\n%s", target_count),
|
|
||||||
plugin_name, target_list);
|
|
||||||
case AuditEventCategory::Http:
|
|
||||||
return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list);
|
|
||||||
case AuditEventCategory::Socket:
|
|
||||||
return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list);
|
|
||||||
case AuditEventCategory::ProcessCreate:
|
|
||||||
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s",
|
|
||||||
"Plugin \"%s\" is requesting to run the following commands:\n%s", target_count),
|
|
||||||
plugin_name, target_list);
|
|
||||||
case AuditEventCategory::Threading:
|
|
||||||
return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name);
|
|
||||||
case AuditEventCategory::ProcessReplace:
|
|
||||||
return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\n%s"), plugin_name, target_list);
|
|
||||||
default:
|
|
||||||
return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds and shows the modal permission prompt. Must run on the GUI thread.
|
|
||||||
bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name,
|
|
||||||
const std::vector<std::string>& unresolved)
|
|
||||||
{
|
|
||||||
wxString target_list;
|
|
||||||
for (const auto& target : unresolved)
|
|
||||||
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
|
||||||
|
|
||||||
wxMessageDialog dialog(nullptr,
|
|
||||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
|
||||||
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
|
||||||
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
|
||||||
return dialog.ShowModal() == wxID_YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
|
||||||
// freshly because the async prompt outlives the caller's stack copy of it.
|
|
||||||
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
|
|
||||||
{
|
|
||||||
PluginInstallState state;
|
|
||||||
if (!PluginManager::instance().get_install_state(plugin_key, state))
|
|
||||||
return;
|
|
||||||
|
|
||||||
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
|
||||||
if (!permission_list)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (const auto& key : keys)
|
|
||||||
persist_permission(plugin_key, state, *permission_list, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
|
||||||
|
|
||||||
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
|
|
||||||
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
|
|
||||||
// no target (by_event), which the prompt does not show as a target.
|
|
||||||
//
|
|
||||||
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
|
|
||||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
|
||||||
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
|
||||||
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
|
||||||
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
|
|
||||||
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
|
||||||
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
|
||||||
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
|
||||||
// re-prompting.
|
|
||||||
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
|
|
||||||
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
|
|
||||||
{
|
|
||||||
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
|
|
||||||
|
|
||||||
if (s_permission_prompt_for_testing)
|
|
||||||
return s_permission_prompt_for_testing(event_name, shown);
|
|
||||||
|
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (wxIsMainThread())
|
|
||||||
return prompt_for_targets(category, plugin_name, event_name, shown);
|
|
||||||
|
|
||||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
|
||||||
return;
|
|
||||||
if (prompt_for_targets(category, plugin_name, event_name, shown))
|
|
||||||
persist_grant(plugin_key, category, by_event, keys);
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
|
|
||||||
// otherwise the user is asked and a Yes is saved.
|
|
||||||
int decide_audited_event(PluginAuditManager& mgr,
|
|
||||||
PluginInstallState& state,
|
|
||||||
const std::string& plugin_key,
|
|
||||||
const std::string& plugin_name,
|
|
||||||
const std::string& event_name,
|
|
||||||
AuditEventCategory category,
|
|
||||||
const std::vector<std::string>& targets)
|
|
||||||
{
|
|
||||||
const bool by_event = targets.empty();
|
|
||||||
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
|
|
||||||
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
|
|
||||||
|
|
||||||
std::vector<std::string> unresolved = keys;
|
|
||||||
if (permission_list) {
|
|
||||||
unresolved.clear();
|
|
||||||
for (const auto& key : keys)
|
|
||||||
if (!has_permission(*permission_list, key))
|
|
||||||
unresolved.push_back(key);
|
|
||||||
if (unresolved.empty())
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
|
|
||||||
// where no dialog may open: what they do must already be granted.
|
|
||||||
if (mgr.releasing_frames())
|
|
||||||
return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"});
|
|
||||||
|
|
||||||
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
|
|
||||||
return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"});
|
|
||||||
|
|
||||||
if (permission_list)
|
|
||||||
for (const auto& key : unresolved)
|
|
||||||
persist_permission(plugin_key, state, *permission_list, key);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool plugin_audit::request_filesystem_read_permissions(const std::string& plugin_key,
|
|
||||||
const std::vector<std::string>& paths)
|
|
||||||
{
|
|
||||||
PluginDescriptor descriptor;
|
|
||||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
PluginInstallState state;
|
|
||||||
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
|
||||||
|
|
||||||
std::vector<std::string> missing;
|
|
||||||
for (const std::string& path : paths) {
|
|
||||||
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
|
|
||||||
missing.push_back(path);
|
|
||||||
}
|
|
||||||
if (missing.empty())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
auto show_dialog = [&descriptor, &missing]() {
|
|
||||||
wxString requested_paths;
|
|
||||||
for (const std::string& path : missing)
|
|
||||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
|
||||||
|
|
||||||
wxMessageDialog dialog(
|
|
||||||
nullptr,
|
|
||||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
|
||||||
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
|
|
||||||
_L("Plugin permissions"),
|
|
||||||
wxYES_NO | wxICON_WARNING);
|
|
||||||
return dialog.ShowModal() == wxID_YES;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool granted = false;
|
|
||||||
if (wxIsMainThread()) {
|
|
||||||
granted = show_dialog();
|
|
||||||
} else {
|
|
||||||
auto result = std::make_shared<std::promise<bool>>();
|
|
||||||
auto future = result->get_future();
|
|
||||||
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
|
|
||||||
wxString requested_paths;
|
|
||||||
for (const std::string& path : missing)
|
|
||||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
|
||||||
|
|
||||||
wxMessageDialog dialog(
|
|
||||||
nullptr,
|
|
||||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
|
||||||
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
|
|
||||||
_L("Plugin permissions"),
|
|
||||||
wxYES_NO | wxICON_WARNING);
|
|
||||||
result->set_value(dialog.ShowModal() == wxID_YES);
|
|
||||||
});
|
|
||||||
granted = future.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!granted)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (state.plugin_name.empty()) {
|
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
|
||||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
|
||||||
state.plugin_name = descriptor.name;
|
|
||||||
state.cloud_uuid = descriptor.cloud_uuid();
|
|
||||||
state.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
|
||||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
|
||||||
}
|
|
||||||
|
|
||||||
int plugin_audit::decide_event(PluginAuditManager& mgr,
|
|
||||||
const std::string& event_name,
|
|
||||||
AuditEventCategory category,
|
|
||||||
const std::vector<std::string>& targets)
|
|
||||||
{
|
|
||||||
PluginInstallState state;
|
|
||||||
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
|
|
||||||
if (!have_install_state) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
|
|
||||||
|
|
||||||
return decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, category, targets);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
|
|
||||||
{
|
|
||||||
s_permission_prompt_for_testing = std::move(prompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#ifndef slic3r_PluginAuditManagerInternal_hpp_
|
|
||||||
#define slic3r_PluginAuditManagerInternal_hpp_
|
|
||||||
|
|
||||||
// Internal seam between the GUI-free audit core and the permission front end. The GUI library
|
|
||||||
// supplies the dialogs, prompts and .install_state.json persistence; the headless probe supplies
|
|
||||||
// a fail-closed implementation that never links wx. Production behavior lives in the GUI
|
|
||||||
// implementation, which is the code that used to be part of PluginAuditManager.cpp.
|
|
||||||
|
|
||||||
#include "PluginAuditManager.hpp"
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace Slic3r::plugin_audit {
|
|
||||||
|
|
||||||
const char* category_name(AuditEventCategory category);
|
|
||||||
|
|
||||||
int report_denied(PluginAuditManager& mgr,
|
|
||||||
const std::string& event_name,
|
|
||||||
const AuditDecision& decision,
|
|
||||||
const std::string& target = {});
|
|
||||||
|
|
||||||
// Used only when the manager is not in observation mode. The GUI implementation consults the
|
|
||||||
// install state, prompts and persists; the probe implementation fails closed.
|
|
||||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
|
||||||
const std::vector<std::string>& paths);
|
|
||||||
|
|
||||||
// Decides an event that no allowed root or running operation covered. The GUI implementation
|
|
||||||
// reads the install state and prompts; the probe implementation fails closed. In observation
|
|
||||||
// mode the audit hook records and returns before reaching this seam.
|
|
||||||
int decide_event(PluginAuditManager& mgr,
|
|
||||||
const std::string& event_name,
|
|
||||||
AuditEventCategory category,
|
|
||||||
const std::vector<std::string>& targets);
|
|
||||||
|
|
||||||
} // namespace Slic3r::plugin_audit
|
|
||||||
|
|
||||||
#endif // slic3r_PluginAuditManagerInternal_hpp_
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
|
|
||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
#include "PythonInterpreter.hpp"
|
#include "PythonInterpreter.hpp"
|
||||||
#include "PluginText.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
|
#include "slic3r/GUI/format.hpp"
|
||||||
|
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <miniz.h>
|
#include <miniz.h>
|
||||||
@@ -91,7 +92,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
|
|||||||
|
|
||||||
const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor);
|
const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor);
|
||||||
if (plugin_root.empty()) {
|
if (plugin_root.empty()) {
|
||||||
error = PLUGIN_U8L("Plugin folder could not be determined.");
|
error = _u8L("Plugin folder could not be determined.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
|
|||||||
resolved_root = fs::absolute(plugin_root, ec);
|
resolved_root = fs::absolute(plugin_root, ec);
|
||||||
}
|
}
|
||||||
if (ec || resolved_root.empty()) {
|
if (ec || resolved_root.empty()) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
|
error = GUI::format(_u8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std:
|
|||||||
const auto removed_count = fs::remove_all(resolved_root, ec);
|
const auto removed_count = fs::remove_all(resolved_root, ec);
|
||||||
if (ec) {
|
if (ec) {
|
||||||
// TRN %1% is a folder path, %2% the system error message.
|
// TRN %1% is a folder path, %2% the system error message.
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
|
error = GUI::format(_u8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,7 +1199,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
|||||||
namespace fs = boost::filesystem;
|
namespace fs = boost::filesystem;
|
||||||
|
|
||||||
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) {
|
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory does not exist: %1%"), plugin_dir.string());
|
error = GUI::format(_u8L("Plugin directory does not exist: %1%"), plugin_dir.string());
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,7 +1216,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
|||||||
if (ext == ".py") {
|
if (ext == ".py") {
|
||||||
if (!py_entry.empty()) {
|
if (!py_entry.empty()) {
|
||||||
// TRN %1% and %2% are file names.
|
// TRN %1% and %2% are file names.
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .py files: %1% and %2%"),
|
error = GUI::format(_u8L("Plugin directory contains multiple .py files: %1% and %2%"),
|
||||||
py_entry.filename().string(), it->path().filename().string());
|
py_entry.filename().string(), it->path().filename().string());
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -1223,7 +1224,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
|||||||
} else if (ext == ".whl") {
|
} else if (ext == ".whl") {
|
||||||
if (!whl_entry.empty()) {
|
if (!whl_entry.empty()) {
|
||||||
// TRN %1% and %2% are file names.
|
// TRN %1% and %2% are file names.
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .whl files: %1% and %2%"),
|
error = GUI::format(_u8L("Plugin directory contains multiple .whl files: %1% and %2%"),
|
||||||
whl_entry.filename().string(), it->path().filename().string());
|
whl_entry.filename().string(), it->path().filename().string());
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1233,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!py_entry.empty() && !whl_entry.empty()) {
|
if (!py_entry.empty() && !whl_entry.empty()) {
|
||||||
error = PLUGIN_U8L("Plugin directory contains both .py and .whl entry files");
|
error = _u8L("Plugin directory contains both .py and .whl entry files");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1242,7 +1243,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
|||||||
if (!whl_entry.empty())
|
if (!whl_entry.empty())
|
||||||
return whl_entry;
|
return whl_entry;
|
||||||
|
|
||||||
error = PLUGIN_U8L("Plugin directory does not contain a .py or .whl entry file");
|
error = _u8L("Plugin directory does not contain a .py or .whl entry file");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1259,34 +1260,34 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
|
|||||||
error.clear();
|
error.clear();
|
||||||
|
|
||||||
if (dir.empty()) {
|
if (dir.empty()) {
|
||||||
error = PLUGIN_U8L("Resource texture directory is not set");
|
error = _u8L("Resource texture directory is not set");
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
if (plugin_root.empty()) {
|
if (plugin_root.empty()) {
|
||||||
error = PLUGIN_U8L("Plugin folder could not be determined.");
|
error = _u8L("Plugin folder could not be determined.");
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
boost::system::error_code ec;
|
boost::system::error_code ec;
|
||||||
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
|
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
|
||||||
if (ec) {
|
if (ec) {
|
||||||
error = PLUGIN_U8L("Resource texture directory could not be resolved");
|
error = _u8L("Resource texture directory could not be resolved");
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs::is_directory(resolved_dir, ec) || ec) {
|
if (!fs::is_directory(resolved_dir, ec) || ec) {
|
||||||
error = PLUGIN_U8L("Resource texture path is not a directory");
|
error = _u8L("Resource texture path is not a directory");
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
|
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
|
||||||
if (ec) {
|
if (ec) {
|
||||||
error = PLUGIN_U8L("Plugin folder could not be resolved");
|
error = _u8L("Plugin folder could not be resolved");
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
|
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
|
||||||
error = PLUGIN_U8L("Resource texture directory is outside the plugin folder");
|
error = _u8L("Resource texture directory is outside the plugin folder");
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,7 +1312,7 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ec) {
|
if (ec) {
|
||||||
error = PLUGIN_U8L("Resource texture directory could not be read");
|
error = _u8L("Resource texture directory could not be read");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
#include "PythonPluginBridge.hpp"
|
#include "PythonPluginBridge.hpp"
|
||||||
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
||||||
#include "libslic3r/Utils.hpp"
|
#include "libslic3r/Utils.hpp"
|
||||||
#include "PluginText.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
|
#include "slic3r/GUI/format.hpp"
|
||||||
|
|
||||||
#include <boost/algorithm/string.hpp>
|
#include <boost/algorithm/string.hpp>
|
||||||
#include <boost/algorithm/string/case_conv.hpp>
|
#include <boost/algorithm/string/case_conv.hpp>
|
||||||
@@ -101,7 +102,7 @@ bool read_local_plugin_package_metadata(const boost::filesystem::path& source_pa
|
|||||||
|
|
||||||
const std::string ext = plugin_package_extension(source_path);
|
const std::string ext = plugin_package_extension(source_path);
|
||||||
if (ext != ".py" && ext != ".whl") {
|
if (ext != ".py" && ext != ".whl") {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
|
error = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,14 +179,14 @@ bool add_wheel_dependencies_to_sys_path(const PluginDescriptor& descriptor,
|
|||||||
std::string extract_error;
|
std::string extract_error;
|
||||||
if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) {
|
if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) {
|
||||||
// TRN %1% is the dependency file, %2% the error detail.
|
// TRN %1% is the dependency file, %2% the error detail.
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
|
error = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string syspath_error;
|
std::string syspath_error;
|
||||||
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
|
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
|
error = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
plugin_paths.push_back(dep_dir.string());
|
plugin_paths.push_back(dep_dir.string());
|
||||||
@@ -247,12 +248,12 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
|
|
||||||
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
||||||
if (!interpreter.is_initialized()) {
|
if (!interpreter.is_initialized()) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Python interpreter not initialized: %1%"), interpreter.last_error());
|
error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!descriptor.is_metadata_valid()) {
|
if (!descriptor.is_metadata_valid()) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
|
error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
|
||||||
if (descriptor.has_error())
|
if (descriptor.has_error())
|
||||||
error += " - " + descriptor.normalized_error();
|
error += " - " + descriptor.normalized_error();
|
||||||
return false;
|
return false;
|
||||||
@@ -273,7 +274,7 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
if (!skip_deps) {
|
if (!skip_deps) {
|
||||||
std::string pkg_install_error;
|
std::string pkg_install_error;
|
||||||
if (!install_packages(descriptor.dependencies, pkg_install_error)) {
|
if (!install_packages(descriptor.dependencies, pkg_install_error)) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
|
error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +293,7 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error);
|
PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error);
|
||||||
if (module == nullptr) {
|
if (module == nullptr) {
|
||||||
bridge.cancel_plugin_capture(descriptor.entry_path);
|
bridge.cancel_plugin_capture(descriptor.entry_path);
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to load plugin module: %1%"), load_error);
|
error = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,13 +308,13 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
|
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
|
||||||
if (!bridge_error.empty()) {
|
if (!bridge_error.empty()) {
|
||||||
capabilities_found.clear();
|
capabilities_found.clear();
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin registration failed: %1%"), bridge_error);
|
error = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error);
|
||||||
if (registration_failed != nullptr)
|
if (registration_failed != nullptr)
|
||||||
*registration_failed = true;
|
*registration_failed = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (capabilities_found.empty()) {
|
if (capabilities_found.empty()) {
|
||||||
error = PLUGIN_U8L("Plugin module did not register any capabilities");
|
error = _u8L("Plugin module did not register any capabilities");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +366,7 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
|
|
||||||
if (!seen_capabilities[type].insert(found.name).second) {
|
if (!seen_capabilities[type].insert(found.name).second) {
|
||||||
// TRN %1% is the capability name, %2% the capability type (e.g. Script).
|
// TRN %1% is the capability name, %2% the capability type (e.g. Script).
|
||||||
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin declares duplicate capability '%1%' for type %2%"),
|
materialization_error = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"),
|
||||||
found.name, PluginConfig::capability_type_label(type));
|
found.name, PluginConfig::capability_type_label(type));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -421,9 +422,9 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
capabilities.push_back(std::move(instance));
|
capabilities.push_back(std::move(instance));
|
||||||
}
|
}
|
||||||
} catch (const std::exception& ex) {
|
} catch (const std::exception& ex) {
|
||||||
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin capability materialization failed: %1%"), ex.what());
|
materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what());
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
materialization_error = PLUGIN_U8L("Plugin capability materialization failed");
|
materialization_error = _u8L("Plugin capability materialization failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,11 +466,11 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
} catch (const std::exception& ex) {
|
} catch (const std::exception& ex) {
|
||||||
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
||||||
// TRN on_load is the name of the plugin's startup function; keep it untranslated.
|
// TRN on_load is the name of the plugin's startup function; keep it untranslated.
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin on_load failed: %1%"), ex.what());
|
error = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what());
|
||||||
return false;
|
return false;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
||||||
error = PLUGIN_U8L("Plugin on_load failed");
|
error = _u8L("Plugin on_load failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +651,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
|||||||
bool backup_created = false;
|
bool backup_created = false;
|
||||||
|
|
||||||
auto fail = [&error](const std::string& message) {
|
auto fail = [&error](const std::string& message) {
|
||||||
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin installation failed: %1%"), message);
|
error = GUI::format(_u8L("Plugin installation failed: %1%"), message);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -658,23 +659,23 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
|||||||
const fs::path source_path = canonical_or_absolute(filepath);
|
const fs::path source_path = canonical_or_absolute(filepath);
|
||||||
|
|
||||||
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
|
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package is not a file: %1%"), filepath.string()));
|
return fail(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string()));
|
||||||
|
|
||||||
const std::string ext = plugin_package_extension(source_path);
|
const std::string ext = plugin_package_extension(source_path);
|
||||||
if (ext != ".py" && ext != ".whl")
|
if (ext != ".py" && ext != ".whl")
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
|
return fail(GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
|
||||||
// The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid).
|
// The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid).
|
||||||
// It comes verbatim from the server's catalog JSON, so reject anything that could escape the
|
// It comes verbatim from the server's catalog JSON, so reject anything that could escape the
|
||||||
// per-user plugin root (traversal, leading dots, separators) before touching the filesystem.
|
// per-user plugin root (traversal, leading dots, separators) before touching the filesystem.
|
||||||
if (is_cloud_install && !is_valid_plugin_id(cloud_uuid))
|
if (is_cloud_install && !is_valid_plugin_id(cloud_uuid))
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
|
return fail(GUI::format(_u8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
|
||||||
|
|
||||||
const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) :
|
const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) :
|
||||||
local_plugin_root();
|
local_plugin_root();
|
||||||
fs::create_directories(plugin_root, ec);
|
fs::create_directories(plugin_root, ec);
|
||||||
if (ec)
|
if (ec)
|
||||||
// TRN %1% is a folder path, %2% the system error message.
|
// TRN %1% is a folder path, %2% the system error message.
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
|
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
|
||||||
|
|
||||||
std::string meta_error;
|
std::string meta_error;
|
||||||
if (is_cloud_install) {
|
if (is_cloud_install) {
|
||||||
@@ -717,7 +718,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
|||||||
fs::rename(final_dir, backup_dir, ec);
|
fs::rename(final_dir, backup_dir, ec);
|
||||||
if (ec)
|
if (ec)
|
||||||
// TRN %1% is a folder path, %2% the system error message.
|
// TRN %1% is a folder path, %2% the system error message.
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
|
return fail(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
|
||||||
backup_created = true;
|
backup_created = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +729,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
|||||||
boost::system::error_code restore_ec;
|
boost::system::error_code restore_ec;
|
||||||
fs::rename(backup_dir, final_dir, restore_ec);
|
fs::rename(backup_dir, final_dir, restore_ec);
|
||||||
}
|
}
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
|
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::copy_file(source_path, dest_file, ec);
|
fs::copy_file(source_path, dest_file, ec);
|
||||||
@@ -739,7 +740,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
|||||||
fs::rename(backup_dir, final_dir, restore_ec);
|
fs::rename(backup_dir, final_dir, restore_ec);
|
||||||
}
|
}
|
||||||
// TRN %1% is a file path, %2% the system error message.
|
// TRN %1% is a file path, %2% the system error message.
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
|
return fail(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update entry_path to the installed location.
|
// Update entry_path to the installed location.
|
||||||
@@ -759,7 +760,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
|
|||||||
fs::remove_all(final_dir, rollback_ec);
|
fs::remove_all(final_dir, rollback_ec);
|
||||||
if (backup_created)
|
if (backup_created)
|
||||||
fs::rename(backup_dir, final_dir, rollback_ec);
|
fs::rename(backup_dir, final_dir, rollback_ec);
|
||||||
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
|
return fail(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ const char* const LOAD_CANCELLED = "__plugin_load_cancelled__";
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void Plugin::release_module()
|
||||||
|
{
|
||||||
|
if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules);
|
||||||
|
module = nullptr;
|
||||||
|
module_name.clear();
|
||||||
|
plugin_sys_paths.clear();
|
||||||
|
plugin_modules.clear();
|
||||||
|
}
|
||||||
|
|
||||||
PluginManager& PluginManager::instance()
|
PluginManager& PluginManager::instance()
|
||||||
{
|
{
|
||||||
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
// Plugin module ownership is part of the loader's contract, not of the registry's GUI-facing
|
|
||||||
// surface. Kept in its own translation unit so the headless probe can link the loader without
|
|
||||||
// pulling PluginManager.cpp (and therefore wx/GUI) into the probe.
|
|
||||||
|
|
||||||
#include "PluginManager.hpp"
|
|
||||||
#include "PythonInterpreter.hpp"
|
|
||||||
|
|
||||||
namespace Slic3r {
|
|
||||||
|
|
||||||
void Plugin::release_module()
|
|
||||||
{
|
|
||||||
if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty())
|
|
||||||
return;
|
|
||||||
|
|
||||||
PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules);
|
|
||||||
module = nullptr;
|
|
||||||
module_name.clear();
|
|
||||||
plugin_sys_paths.clear();
|
|
||||||
plugin_modules.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
|
||||||
@@ -1,565 +0,0 @@
|
|||||||
// Headless, record-only plugin permission probe.
|
|
||||||
//
|
|
||||||
// This is a developer/security-analysis tool, not a second production loader. It reuses the
|
|
||||||
// embedded interpreter, the production plugin_loader sequence and the GUI-free `orca` bindings
|
|
||||||
// (capability types plus the geometry/model/slicing host domains), so the observations come from
|
|
||||||
// the same audit classification and target extraction as the GUI. It then lets every audited
|
|
||||||
// operation continue instead of consulting permission grants or dialogs. Host domains that need
|
|
||||||
// the live GUI (app, presets, ui) and plugin.storage are registered as explicit unsupported
|
|
||||||
// errors; the config API reports an empty config and refuses writes.
|
|
||||||
//
|
|
||||||
// It is NOT a sandbox. Plugin code runs with the probe's filesystem, network and process
|
|
||||||
// privileges. The temporary data directory only keeps OrcaSlicer's own state out of the user's
|
|
||||||
// tree; it does not confine plugin-authored code. Use OS-level restrictions (container, user,
|
|
||||||
// seccomp/AppArmor, firewall) when the package is not trusted.
|
|
||||||
//
|
|
||||||
// The report describes only the paths this run exercised. CPython audit events are observations of
|
|
||||||
// particular operations, not a notification for every Python call, so absence of a category in the
|
|
||||||
// report is not evidence that the plugin cannot perform it.
|
|
||||||
|
|
||||||
#include "PluginAuditManager.hpp"
|
|
||||||
#include "PluginDescriptor.hpp"
|
|
||||||
#include "PluginFsUtils.hpp"
|
|
||||||
#include "PluginLoader.hpp"
|
|
||||||
#include "PluginManager.hpp"
|
|
||||||
#include "PluginProbeCollector.hpp"
|
|
||||||
#include "PythonInterpreter.hpp"
|
|
||||||
#include "PythonPluginInterface.hpp"
|
|
||||||
|
|
||||||
#include "libslic3r/LifecycleEvents.hpp"
|
|
||||||
#include "libslic3r/Utils.hpp"
|
|
||||||
|
|
||||||
#include <boost/algorithm/string/case_conv.hpp>
|
|
||||||
#include <boost/filesystem.hpp>
|
|
||||||
#include <boost/filesystem/operations.hpp>
|
|
||||||
#include <boost/filesystem/path.hpp>
|
|
||||||
#include <boost/system/error_code.hpp>
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <pybind11/embed.h>
|
|
||||||
#include <pybind11/gil.h>
|
|
||||||
#include <pybind11/pytypes.h>
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <exception>
|
|
||||||
#include <iostream>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace fs = boost::filesystem;
|
|
||||||
namespace py = pybind11;
|
|
||||||
|
|
||||||
using namespace Slic3r;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
constexpr const char* kLoadPhase = "load:import+registration+on_load";
|
|
||||||
|
|
||||||
struct Options
|
|
||||||
{
|
|
||||||
fs::path plugin_path;
|
|
||||||
bool json = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
void print_usage(std::ostream& out)
|
|
||||||
{
|
|
||||||
out << "Usage: orca-plugin-probe [--json] <plugin.py|plugin.whl>\n"
|
|
||||||
"\n"
|
|
||||||
"Runs a plugin in a temporary data/working directory and reports the CPython audit\n"
|
|
||||||
"events observed while its import, registration, on_load and discoverable callbacks\n"
|
|
||||||
"execute. The probe does not prompt for permissions, write grants, install dependencies,\n"
|
|
||||||
"or start the OrcaSlicer GUI.\n"
|
|
||||||
"\n"
|
|
||||||
" --json Emit the report as JSON (default: human-readable text)\n"
|
|
||||||
" --help Show this message\n"
|
|
||||||
"\n"
|
|
||||||
"WARNING: this is not a sandbox. Plugin Python code runs with this process's privileges.\n"
|
|
||||||
"Results are observational and non-exhaustive. Use OS-level restrictions for untrusted\n"
|
|
||||||
"packages.\n"
|
|
||||||
"\n"
|
|
||||||
"Exit status: 0 plugin loaded, 1 initialization/load failure, 2 usage error.\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
bool parse_options(int argc, char** argv, Options& options, std::string& error)
|
|
||||||
{
|
|
||||||
for (int index = 1; index < argc; ++index) {
|
|
||||||
const std::string argument = argv[index];
|
|
||||||
if (argument == "--help" || argument == "-h") {
|
|
||||||
print_usage(std::cout);
|
|
||||||
std::exit(0);
|
|
||||||
}
|
|
||||||
if (argument == "--json") {
|
|
||||||
options.json = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!argument.empty() && argument.front() == '-') {
|
|
||||||
error = "Unknown option: " + argument;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!options.plugin_path.empty()) {
|
|
||||||
error = "Only one plugin package may be probed at a time.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
options.plugin_path = fs::path(argument);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.plugin_path.empty()) {
|
|
||||||
error = "No plugin package given.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep every write OrcaSlicer makes during the run inside a throwaway tree, and keep relative
|
|
||||||
// paths opened by plugin code out of the caller's working directory. This is hygiene, not
|
|
||||||
// confinement: an absolute path supplied by the plugin is still opened normally.
|
|
||||||
class ScopedProbeWorkspace
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
ScopedProbeWorkspace()
|
|
||||||
: m_root(fs::temp_directory_path() / fs::unique_path("orca-plugin-probe-%%%%-%%%%-%%%%"))
|
|
||||||
, m_previous_data_dir(data_dir())
|
|
||||||
, m_previous_working_dir(fs::current_path())
|
|
||||||
{
|
|
||||||
fs::create_directories(m_root);
|
|
||||||
set_data_dir(m_root.string());
|
|
||||||
fs::current_path(m_root);
|
|
||||||
m_moved = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
~ScopedProbeWorkspace()
|
|
||||||
{
|
|
||||||
boost::system::error_code ec;
|
|
||||||
set_data_dir(m_previous_data_dir);
|
|
||||||
if (m_moved)
|
|
||||||
fs::current_path(m_previous_working_dir, ec);
|
|
||||||
fs::remove_all(m_root, ec);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fs::path& root() const { return m_root; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
fs::path m_root;
|
|
||||||
std::string m_previous_data_dir;
|
|
||||||
fs::path m_previous_working_dir;
|
|
||||||
bool m_moved = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ScopedObservationObserver
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit ScopedObservationObserver(PluginProbeCollector& collector) : m_collector(collector)
|
|
||||||
{
|
|
||||||
PluginAuditManager::instance().set_observation_observer(
|
|
||||||
[this](const AuditObservation& observation) { m_collector.record(observation); });
|
|
||||||
}
|
|
||||||
|
|
||||||
~ScopedObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); }
|
|
||||||
|
|
||||||
ScopedObservationObserver(const ScopedObservationObserver&) = delete;
|
|
||||||
ScopedObservationObserver& operator=(const ScopedObservationObserver&) = delete;
|
|
||||||
|
|
||||||
private:
|
|
||||||
PluginProbeCollector& m_collector;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool prepare_descriptor(const fs::path& plugin_path, PluginDescriptor& descriptor, std::string& error)
|
|
||||||
{
|
|
||||||
boost::system::error_code ec;
|
|
||||||
const fs::path canonical = fs::weakly_canonical(plugin_path, ec);
|
|
||||||
if (ec || !fs::exists(canonical) || !fs::is_regular_file(canonical)) {
|
|
||||||
error = "Plugin package is not a readable file: " + plugin_path.string();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string extension = canonical.extension().string();
|
|
||||||
boost::algorithm::to_lower(extension);
|
|
||||||
if (extension != ".py" && extension != ".whl") {
|
|
||||||
error = "Plugin package must be a .py or .whl file, got: " + extension;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extension == ".whl") {
|
|
||||||
if (!read_wheel_plugin_metadata(canonical, descriptor, error))
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
if (!read_python_plugin_metadata(canonical, descriptor, error))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
descriptor.entry_path = canonical.string();
|
|
||||||
descriptor.plugin_key = make_local_plugin_key(canonical.stem().string());
|
|
||||||
descriptor.metadata_valid = true;
|
|
||||||
// Leaving plugin_root empty keeps the probe independent of any .install_state.json next to
|
|
||||||
// the package and avoids the production sidecar path.
|
|
||||||
descriptor.plugin_root.clear();
|
|
||||||
if (descriptor.name.empty())
|
|
||||||
descriptor.name = descriptor.plugin_key;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void redirect_python_stdout()
|
|
||||||
{
|
|
||||||
py::gil_scoped_acquire gil;
|
|
||||||
py::object sys = py::module_::import("sys");
|
|
||||||
// Keep plugin prints from corrupting the C++ report on stdout. sys.stderr is the interpreter's
|
|
||||||
// tee to the temporary log, so plugin output remains available there.
|
|
||||||
sys.attr("stdout") = sys.attr("stderr");
|
|
||||||
}
|
|
||||||
|
|
||||||
template<class Function>
|
|
||||||
void invoke_callback(const std::shared_ptr<PluginCapabilityInterface>& capability,
|
|
||||||
const std::string& phase,
|
|
||||||
const std::string& callback,
|
|
||||||
PluginProbeCollector& collector,
|
|
||||||
Function&& function)
|
|
||||||
{
|
|
||||||
ScopedPluginAuditPhase phase_scope(phase);
|
|
||||||
try {
|
|
||||||
std::forward<Function>(function)();
|
|
||||||
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "invoked", {}});
|
|
||||||
} catch (const std::exception& error) {
|
|
||||||
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", error.what()});
|
|
||||||
} catch (...) {
|
|
||||||
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", "non-standard exception"});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProbeCapability
|
|
||||||
{
|
|
||||||
std::string name;
|
|
||||||
std::string type;
|
|
||||||
bool enabled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::vector<ProbeCapability> describe_capabilities(const Plugin& plugin)
|
|
||||||
{
|
|
||||||
std::vector<ProbeCapability> capabilities;
|
|
||||||
capabilities.reserve(plugin.capabilities.size());
|
|
||||||
for (const auto& capability : plugin.capabilities) {
|
|
||||||
if (!capability)
|
|
||||||
continue;
|
|
||||||
capabilities.push_back({capability->name(), plugin_capability_type_to_string(capability->type()), capability->is_enabled()});
|
|
||||||
}
|
|
||||||
return capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
void exercise_callbacks(const Plugin& plugin, PluginProbeCollector& collector)
|
|
||||||
{
|
|
||||||
// on_load() already ran inside plugin_loader::load. The callbacks below are the additional
|
|
||||||
// entry points a loaded capability exposes to the host. Synthetic inputs are used; a callback
|
|
||||||
// may behave differently with a real application state, which the report cannot capture.
|
|
||||||
static constexpr LifecycleEvent lifecycle_events[] = {
|
|
||||||
LifecycleEvent::NewProject, LifecycleEvent::ProjectOpened, LifecycleEvent::ProjectBeforeSave,
|
|
||||||
LifecycleEvent::ProjectAfterSave, LifecycleEvent::ProjectClosed, LifecycleEvent::ProjectDirtyChanged,
|
|
||||||
LifecycleEvent::SliceStarted, LifecycleEvent::SliceGeometryFinished, LifecycleEvent::GCodeExportStarted,
|
|
||||||
LifecycleEvent::GCodeExportFinished, LifecycleEvent::SlicingJobComplete, LifecycleEvent::ObjectAdded,
|
|
||||||
LifecycleEvent::ObjectDeleted, LifecycleEvent::ObjectTransformed, LifecycleEvent::ObjectChanged,
|
|
||||||
LifecycleEvent::ObjectRenamed, LifecycleEvent::PlateCreated, LifecycleEvent::PlateDeleted,
|
|
||||||
LifecycleEvent::PlateSelected, LifecycleEvent::PlateRenamed, LifecycleEvent::PresetSelected,
|
|
||||||
LifecycleEvent::PresetSaved, LifecycleEvent::PrintStateChanged, LifecycleEvent::DeviceOnline,
|
|
||||||
LifecycleEvent::DeviceOffline, LifecycleEvent::DeviceDiscovered, LifecycleEvent::DeviceSelected,
|
|
||||||
LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished, LifecycleEvent::PrintJobStarted,
|
|
||||||
LifecycleEvent::PrintJobFinished, LifecycleEvent::SendJobStarted, LifecycleEvent::SendJobFinished,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const auto& capability : plugin.capabilities) {
|
|
||||||
if (!capability)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
invoke_callback(capability, "callback:get_default_config", "get_default_config", collector,
|
|
||||||
[&capability]() { (void) capability->get_default_config(); });
|
|
||||||
|
|
||||||
if (capability->config_ui_available()) {
|
|
||||||
invoke_callback(capability, "callback:get_config_ui", "get_config_ui", collector,
|
|
||||||
[&capability]() { (void) capability->get_config_ui(); });
|
|
||||||
} else {
|
|
||||||
collector.record_callback(
|
|
||||||
{capability->name(), "callback:get_config_ui", "get_config_ui", "skipped", "has_config_ui() is false"});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const LifecycleEvent event : lifecycle_events) {
|
|
||||||
const std::string event_name = lifecycle_event_to_string(event);
|
|
||||||
const std::string phase = "callback:on_lifecycle_event(" + event_name + ")";
|
|
||||||
LifecycleEventContext context;
|
|
||||||
context.source = "orca-plugin-probe";
|
|
||||||
context.msg = "synthetic event generated by the permission probe";
|
|
||||||
invoke_callback(capability, phase, "on_lifecycle_event(" + event_name + ")", collector,
|
|
||||||
[&capability, event, &context]() { capability->on_lifecycle_event(event, context); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancellation is a load-time path; calling it after a successful load would misrepresent
|
|
||||||
// the capability's real lifecycle. Report it as skipped instead.
|
|
||||||
collector.record_callback(
|
|
||||||
{capability->name(), "callback:on_cancelled", "on_cancelled", "skipped", "not invoked after a successful load"});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json observation_json(const AuditObservation& observation)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
{"plugin", observation.plugin_key},
|
|
||||||
{"capability", observation.capability},
|
|
||||||
{"phase", observation.phase.empty() ? "unattributed" : observation.phase},
|
|
||||||
{"event", observation.event_name},
|
|
||||||
{"category", observation.category},
|
|
||||||
{"targets", observation.targets},
|
|
||||||
{"continued", observation.continued},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json callback_json(const ProbeCallbackRecord& callback)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
{"capability", callback.capability}, {"phase", callback.phase}, {"callback", callback.callback},
|
|
||||||
{"status", callback.status}, {"detail", callback.detail},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json capability_json(const ProbeCapability& capability)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
{"name", capability.name},
|
|
||||||
{"type", capability.type},
|
|
||||||
{"enabled", capability.enabled},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json build_report(const Options& options,
|
|
||||||
const PluginProbeCollector& collector,
|
|
||||||
const std::vector<ProbeCapability>& capabilities,
|
|
||||||
const std::string& plugin_key,
|
|
||||||
bool python_initialized,
|
|
||||||
const std::string& python_error,
|
|
||||||
bool loaded,
|
|
||||||
const std::string& load_error,
|
|
||||||
const std::string& data_dir)
|
|
||||||
{
|
|
||||||
nlohmann::json observations = nlohmann::json::array();
|
|
||||||
for (const AuditObservation& observation : collector.observations())
|
|
||||||
observations.push_back(observation_json(observation));
|
|
||||||
|
|
||||||
nlohmann::json callbacks = nlohmann::json::array();
|
|
||||||
for (const ProbeCallbackRecord& callback : collector.callbacks())
|
|
||||||
callbacks.push_back(callback_json(callback));
|
|
||||||
|
|
||||||
nlohmann::json capability_list = nlohmann::json::array();
|
|
||||||
for (const ProbeCapability& capability : capabilities)
|
|
||||||
capability_list.push_back(capability_json(capability));
|
|
||||||
|
|
||||||
return {
|
|
||||||
{"tool", "orca-plugin-permission-probe"},
|
|
||||||
{"mode", "record-only"},
|
|
||||||
{"observed_by_this_run_only", true},
|
|
||||||
{"plugin",
|
|
||||||
{
|
|
||||||
{"path", options.plugin_path.string()},
|
|
||||||
{"key", plugin_key},
|
|
||||||
}},
|
|
||||||
{"environment",
|
|
||||||
{
|
|
||||||
{"python_initialized", python_initialized},
|
|
||||||
{"python_error", python_error},
|
|
||||||
{"temporary_data_dir", data_dir},
|
|
||||||
{"temporary_data_dir_removed_on_exit", true},
|
|
||||||
{"working_directory", data_dir},
|
|
||||||
{"interactive_prompts", false},
|
|
||||||
{"grants_persisted", false},
|
|
||||||
}},
|
|
||||||
{"load", {{"ok", loaded}, {"error", load_error}, {"capabilities", capability_list}}},
|
|
||||||
{"callbacks", callbacks},
|
|
||||||
{"observations", observations},
|
|
||||||
{"summary", collector.summary()},
|
|
||||||
{"coverage",
|
|
||||||
{
|
|
||||||
{"exhaustive", false},
|
|
||||||
{"sandbox", false},
|
|
||||||
{"plugin_code_executed", true},
|
|
||||||
{"notes",
|
|
||||||
{
|
|
||||||
"Only the code paths this probe exercised are represented. Audit events cover "
|
|
||||||
"particular operations, not every Python call.",
|
|
||||||
"The probe does not start the GUI. Host APIs that require a live application or "
|
|
||||||
"UI (orca.host.app/presets/ui) and plugin.storage report an explicit unsupported "
|
|
||||||
"error and are reported as failed callbacks.",
|
|
||||||
"The probe links libslic3r for core geometry/print types, but not libslic3r_gui "
|
|
||||||
"or wx.",
|
|
||||||
"The temporary data directory isolates OrcaSlicer state, not plugin-authored file, "
|
|
||||||
"network or process access. Use OS-level restrictions for untrusted packages.",
|
|
||||||
"The loader may extract adjacent .whl dependencies into the package's directory; "
|
|
||||||
"probe a copy of the package when that side effect is unwanted.",
|
|
||||||
"Import, registration and on_load share one loader phase; the production loader "
|
|
||||||
"does not expose a finer boundary without changing its behavior.",
|
|
||||||
"Callbacks run with synthetic inputs, which can change plugin control flow "
|
|
||||||
"compared with a real application session.",
|
|
||||||
"continued=true means the audit hook did not block the operation. A surrounding "
|
|
||||||
"callback or load step can still fail afterwards, as reported in callbacks/load.",
|
|
||||||
"Events raised on a plugin-spawned thread carry no phase label and are reported "
|
|
||||||
"as unattributed.",
|
|
||||||
"Plugin code can spawn threads/processes, write anywhere this process can, or "
|
|
||||||
"terminate the process. None of that is contained by the probe.",
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void print_text_report(const nlohmann::json& report)
|
|
||||||
{
|
|
||||||
std::cout << "OrcaSlicer plugin permission probe (record-only)\n"
|
|
||||||
"WARNING: observational only; not exhaustive and not a sandbox.\n\n";
|
|
||||||
std::cout << "Plugin: " << report["plugin"]["path"].get<std::string>() << "\n";
|
|
||||||
std::cout << "Python initialized: " << (report["environment"]["python_initialized"].get<bool>() ? "yes" : "no") << "\n";
|
|
||||||
if (!report["environment"]["python_error"].get<std::string>().empty())
|
|
||||||
std::cout << "Python error: " << report["environment"]["python_error"].get<std::string>() << "\n";
|
|
||||||
std::cout << "Load: " << (report["load"]["ok"].get<bool>() ? "ok" : "failed") << "\n";
|
|
||||||
if (!report["load"]["error"].get<std::string>().empty())
|
|
||||||
std::cout << "Load error: " << report["load"]["error"].get<std::string>() << "\n";
|
|
||||||
|
|
||||||
std::cout << "\nCapabilities (" << report["load"]["capabilities"].size() << "):\n";
|
|
||||||
for (const auto& capability : report["load"]["capabilities"])
|
|
||||||
std::cout << " " << capability["name"].get<std::string>() << " [" << capability["type"].get<std::string>()
|
|
||||||
<< "] enabled=" << (capability["enabled"].get<bool>() ? "yes" : "no") << "\n";
|
|
||||||
|
|
||||||
std::cout << "\nCallbacks (" << report["callbacks"].size() << "):\n";
|
|
||||||
for (const auto& callback : report["callbacks"]) {
|
|
||||||
std::cout << " " << callback["status"].get<std::string>() << " " << callback["callback"].get<std::string>();
|
|
||||||
if (!callback["capability"].get<std::string>().empty())
|
|
||||||
std::cout << " capability=" << callback["capability"].get<std::string>();
|
|
||||||
if (!callback["detail"].get<std::string>().empty())
|
|
||||||
std::cout << " - " << callback["detail"].get<std::string>();
|
|
||||||
std::cout << "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "\nObserved audit events (" << report["observations"].size() << "):\n";
|
|
||||||
for (const auto& observation : report["observations"]) {
|
|
||||||
std::cout << " phase=" << observation["phase"].get<std::string>() << " event=" << observation["event"].get<std::string>()
|
|
||||||
<< " category=" << observation["category"].get<std::string>();
|
|
||||||
if (!observation["capability"].get<std::string>().empty())
|
|
||||||
std::cout << " capability=" << observation["capability"].get<std::string>();
|
|
||||||
if (!observation["targets"].empty()) {
|
|
||||||
std::cout << " targets=";
|
|
||||||
bool first = true;
|
|
||||||
for (const auto& target : observation["targets"]) {
|
|
||||||
if (!first)
|
|
||||||
std::cout << ",";
|
|
||||||
std::cout << target.get<std::string>();
|
|
||||||
first = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::cout << " continued=" << (observation["continued"].get<bool>() ? "yes" : "no") << "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "\nObserved permission categories:\n";
|
|
||||||
for (const auto& [category, detail] : report["summary"]["categories"].items()) {
|
|
||||||
std::cout << " " << category << ":";
|
|
||||||
for (const auto& event : detail["events"])
|
|
||||||
std::cout << " " << event.get<std::string>();
|
|
||||||
if (!detail["targets"].empty()) {
|
|
||||||
std::cout << " -> ";
|
|
||||||
bool first = true;
|
|
||||||
for (const auto& target : detail["targets"]) {
|
|
||||||
if (!first)
|
|
||||||
std::cout << ", ";
|
|
||||||
std::cout << target.get<std::string>();
|
|
||||||
first = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::cout << "\n";
|
|
||||||
}
|
|
||||||
if (report["summary"]["categories"].empty())
|
|
||||||
std::cout << " (none observed)\n";
|
|
||||||
|
|
||||||
std::cout << "\nCoverage limits:\n";
|
|
||||||
for (const auto& note : report["coverage"]["notes"])
|
|
||||||
std::cout << " - " << note.get<std::string>() << "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
int main(int argc, char** argv)
|
|
||||||
{
|
|
||||||
Options options;
|
|
||||||
std::string option_error;
|
|
||||||
if (!parse_options(argc, argv, options, option_error)) {
|
|
||||||
std::cerr << "orca-plugin-probe: " << option_error << "\n\n";
|
|
||||||
print_usage(std::cerr);
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
boost::system::error_code ec;
|
|
||||||
const fs::path canonical_plugin = fs::weakly_canonical(options.plugin_path, ec);
|
|
||||||
if (!ec)
|
|
||||||
options.plugin_path = canonical_plugin;
|
|
||||||
|
|
||||||
ScopedProbeWorkspace workspace;
|
|
||||||
PluginProbeCollector collector;
|
|
||||||
ScopedObservationObserver observer(collector);
|
|
||||||
|
|
||||||
PluginDescriptor descriptor;
|
|
||||||
std::string descriptor_error;
|
|
||||||
const bool descriptor_ready = prepare_descriptor(options.plugin_path, descriptor, descriptor_error);
|
|
||||||
|
|
||||||
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
|
||||||
bool python_initialized = false;
|
|
||||||
std::string python_error;
|
|
||||||
if (descriptor_ready) {
|
|
||||||
python_initialized = interpreter.initialize();
|
|
||||||
if (!python_initialized)
|
|
||||||
python_error = interpreter.last_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (python_initialized) {
|
|
||||||
try {
|
|
||||||
redirect_python_stdout();
|
|
||||||
} catch (const std::exception& error) {
|
|
||||||
python_error = std::string("Could not redirect Python stdout: ") + error.what();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool loaded = false;
|
|
||||||
std::string load_error = descriptor_error;
|
|
||||||
Plugin plugin;
|
|
||||||
std::vector<ProbeCapability> capabilities;
|
|
||||||
|
|
||||||
if (python_initialized && descriptor_ready) {
|
|
||||||
// The outer context covers import, registration and on_load in plugin_loader::load; the
|
|
||||||
// per-callback scopes below cover the follow-up calls. The production loader is unchanged.
|
|
||||||
ScopedPluginAuditContext audit_context(descriptor.plugin_key);
|
|
||||||
{
|
|
||||||
ScopedPluginAuditPhase load_phase(kLoadPhase);
|
|
||||||
loaded = plugin_loader::load(descriptor, /*skip_deps=*/true, /*capabilities_to_enable=*/{}, nullptr, plugin, load_error);
|
|
||||||
collector.record_callback({std::string{}, kLoadPhase, "plugin_loader::load", loaded ? "invoked" : "failed", load_error});
|
|
||||||
collector.record_callback({std::string{}, "callback:on_load", "on_load", loaded ? "invoked" : "skipped",
|
|
||||||
loaded ? "invoked by plugin_loader::load for each materialized capability" :
|
|
||||||
"plugin_loader::load failed before completing on_load"});
|
|
||||||
}
|
|
||||||
|
|
||||||
capabilities = describe_capabilities(plugin);
|
|
||||||
|
|
||||||
if (loaded)
|
|
||||||
exercise_callbacks(plugin, collector);
|
|
||||||
|
|
||||||
if (loaded) {
|
|
||||||
ScopedPluginAuditPhase unload_phase("callback:on_unload");
|
|
||||||
plugin_loader::unload(plugin);
|
|
||||||
collector.record_callback({std::string{}, "callback:on_unload", "on_unload", "invoked",
|
|
||||||
"invoked by plugin_loader::unload; per-capability failures are logged by the loader"});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interpreter.is_initialized())
|
|
||||||
interpreter.shutdown();
|
|
||||||
|
|
||||||
const nlohmann::json report = build_report(options, collector, capabilities,
|
|
||||||
descriptor.plugin_key.empty() ? make_local_plugin_key(options.plugin_path.stem().string()) :
|
|
||||||
descriptor.plugin_key,
|
|
||||||
python_initialized, python_error, loaded, load_error, workspace.root().string());
|
|
||||||
|
|
||||||
if (options.json)
|
|
||||||
std::cout << report.dump(2, ' ', false, nlohmann::json::error_handler_t::replace) << "\n";
|
|
||||||
else
|
|
||||||
print_text_report(report);
|
|
||||||
|
|
||||||
return loaded ? 0 : 1;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
#ifndef slic3r_PluginProbeCollector_hpp_
|
|
||||||
#define slic3r_PluginProbeCollector_hpp_
|
|
||||||
|
|
||||||
// Record-only collection for the headless plugin permission probe. This is deliberately a plain
|
|
||||||
// in-memory store: it has no dialog, grant or persistence path, and it never decides whether an
|
|
||||||
// audited operation may proceed. Keeping it independent of wx/Python also makes its grouping and
|
|
||||||
// phase attribution testable without starting the interpreter.
|
|
||||||
|
|
||||||
#include "PluginAuditManager.hpp"
|
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
|
|
||||||
#include <map>
|
|
||||||
#include <mutex>
|
|
||||||
#include <set>
|
|
||||||
#include <string>
|
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace Slic3r {
|
|
||||||
|
|
||||||
// What the probe attempted and what came back. `status` is one of "invoked", "failed" or
|
|
||||||
// "skipped"; a skipped callback is reported so the absence of observations is not mistaken for a
|
|
||||||
// clean result.
|
|
||||||
struct ProbeCallbackRecord
|
|
||||||
{
|
|
||||||
std::string capability;
|
|
||||||
std::string phase;
|
|
||||||
std::string callback;
|
|
||||||
std::string status;
|
|
||||||
std::string detail;
|
|
||||||
};
|
|
||||||
|
|
||||||
class PluginProbeCollector
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
void record(const AuditObservation& observation)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
m_observations.push_back(observation);
|
|
||||||
}
|
|
||||||
|
|
||||||
void record_callback(ProbeCallbackRecord record)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
m_callbacks.push_back(std::move(record));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<AuditObservation> observations() const
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
return m_observations;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<ProbeCallbackRecord> callbacks() const
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
return m_callbacks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groups the run by the permission categories the audit hook already assigned, and lists the
|
|
||||||
// distinct targets and event names seen in each. This is a view of this run only; it says
|
|
||||||
// nothing about code paths the plugin did not exercise.
|
|
||||||
nlohmann::json summary() const
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
|
||||||
|
|
||||||
std::map<std::string, std::set<std::string>> events;
|
|
||||||
std::map<std::string, std::set<std::string>> targets;
|
|
||||||
for (const AuditObservation& observation : m_observations) {
|
|
||||||
events[observation.category].insert(observation.event_name);
|
|
||||||
targets[observation.category].insert(observation.targets.begin(), observation.targets.end());
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json categories = nlohmann::json::object();
|
|
||||||
for (const auto& [category, category_events] : events) {
|
|
||||||
categories[category] = {
|
|
||||||
{"events", category_events},
|
|
||||||
{"targets", targets[category]},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
{"observed_event_count", m_observations.size()},
|
|
||||||
{"categories", categories},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
mutable std::mutex m_mutex;
|
|
||||||
std::vector<AuditObservation> m_observations;
|
|
||||||
std::vector<ProbeCallbackRecord> m_callbacks;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
|
||||||
|
|
||||||
#endif // slic3r_PluginProbeCollector_hpp_
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
// User-facing plugin messages are translated in the GUI build. The headless probe links neither
|
|
||||||
// libslic3r_gui nor wx, so it formats the same English text without wxGetTranslation. The GUI path
|
|
||||||
// expands to exactly the calls the plugin sources used before this header existed.
|
|
||||||
|
|
||||||
#if defined(ORCA_PLUGIN_PROBE_BUILD)
|
|
||||||
|
|
||||||
#include "libslic3r/format.hpp"
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
namespace Slic3r::plugin_text {
|
|
||||||
|
|
||||||
inline std::string translate_utf8(const char* text) { return text == nullptr ? std::string{} : std::string(text); }
|
|
||||||
inline std::string translate_utf8(const std::string& text) { return text; }
|
|
||||||
|
|
||||||
template<typename... TArgs>
|
|
||||||
inline std::string format(const char* fmt, TArgs&&... args)
|
|
||||||
{
|
|
||||||
return Slic3r::format(fmt, std::forward<TArgs>(args)...);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<typename... TArgs>
|
|
||||||
inline std::string format(const std::string& fmt, TArgs&&... args)
|
|
||||||
{
|
|
||||||
return Slic3r::format(fmt, std::forward<TArgs>(args)...);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r::plugin_text
|
|
||||||
|
|
||||||
#define PLUGIN_U8L(text) ::Slic3r::plugin_text::translate_utf8(text)
|
|
||||||
#define PLUGIN_FORMAT(...) ::Slic3r::plugin_text::format(__VA_ARGS__)
|
|
||||||
|
|
||||||
#else
|
|
||||||
|
|
||||||
#include "slic3r/GUI/I18N.hpp"
|
|
||||||
#include "slic3r/GUI/format.hpp"
|
|
||||||
|
|
||||||
#define PLUGIN_U8L(text) _u8L(text)
|
|
||||||
#define PLUGIN_FORMAT(...) GUI::format(__VA_ARGS__)
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
|
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <pybind11/gil.h>
|
#include <pybind11/gil.h>
|
||||||
#include "PluginText.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
|
|
||||||
namespace py = pybind11;
|
namespace py = pybind11;
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
|||||||
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
|
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
|
||||||
}
|
}
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
error = PLUGIN_U8L("Plugin filesystem read permission request denied");
|
error = _u8L("Plugin filesystem read permission request denied");
|
||||||
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
||||||
discard_pending_for_key();
|
discard_pending_for_key();
|
||||||
clear_active_key();
|
clear_active_key();
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
// libslic3r references NanoSVG without carrying its header-only implementation. This probe links
|
|
||||||
// libslic3r without the GUI BitmapCache.cpp that supplies the implementation in the application.
|
|
||||||
#define NANOSVG_IMPLEMENTATION
|
|
||||||
#include "nanosvg/nanosvg.h"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Headless replacement for the GUI permission front end. The probe installs an observation
|
|
||||||
// observer, so the audit hook records and returns before reaching this seam. If that observer is
|
|
||||||
// ever absent, permission decisions fail closed instead of consulting GUI or install state.
|
|
||||||
|
|
||||||
#include "slic3r/plugin/PluginAuditManagerInternal.hpp"
|
|
||||||
|
|
||||||
#include <boost/log/trivial.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace Slic3r::plugin_audit {
|
|
||||||
|
|
||||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
|
||||||
const std::vector<std::string>& paths)
|
|
||||||
{
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "[PROBE] Refusing filesystem read permission request for " << plugin_key
|
|
||||||
<< " (" << paths.size() << " path(s)): the headless probe does not evaluate grants";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int decide_event(PluginAuditManager& mgr,
|
|
||||||
const std::string& event_name,
|
|
||||||
AuditEventCategory category,
|
|
||||||
const std::vector<std::string>& targets)
|
|
||||||
{
|
|
||||||
(void) category;
|
|
||||||
(void) targets;
|
|
||||||
return report_denied(mgr, event_name, {false, "permission decisions are unavailable in the headless probe"});
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r::plugin_audit
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// Probe-only `orca.host` registration. The GUI-free domains use the production bindings; the
|
|
||||||
// domains that need a live GUI application, main window or preset bundle are exposed as explicit
|
|
||||||
// unsupported submodules instead of being compiled (and linked) into the probe.
|
|
||||||
//
|
|
||||||
// A plugin that touches one of those domains fails its own callback with a clear message; the
|
|
||||||
// failure is recorded by the probe's callback report rather than becoming a link dependency.
|
|
||||||
|
|
||||||
#include "slic3r/plugin/host/PluginHost.hpp"
|
|
||||||
#include "slic3r/plugin/host/PluginHostBindings.hpp"
|
|
||||||
|
|
||||||
#include <pybind11/pybind11.h>
|
|
||||||
#include <pybind11/pytypes.h>
|
|
||||||
|
|
||||||
#include <stdexcept>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace py = pybind11;
|
|
||||||
|
|
||||||
namespace Slic3r {
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
void add_unavailable_domain(py::module_& host, const char* name, const char* description)
|
|
||||||
{
|
|
||||||
py::module_ domain = host.def_submodule(name, description);
|
|
||||||
const std::string prefix = std::string("orca.host.") + name + ".";
|
|
||||||
domain.attr("__getattr__") = py::cpp_function([prefix](const std::string& attribute) -> py::object {
|
|
||||||
throw std::runtime_error(prefix + attribute +
|
|
||||||
" is not available in the headless plugin permission probe because it needs the GUI application");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void add_unavailable_function(py::module_& host, const char* name)
|
|
||||||
{
|
|
||||||
// Production registers these in PluginHostApp::register_app(). Do not overwrite a slot that
|
|
||||||
// another registrar already claimed (the model domain binds the Model class through
|
|
||||||
// register_model); the probe only fills the GUI-backed accessors that are absent here.
|
|
||||||
if (py::hasattr(host, name))
|
|
||||||
return;
|
|
||||||
|
|
||||||
const std::string detail = std::string("orca.host.") + name +
|
|
||||||
" is not available in the headless plugin permission probe because it needs the GUI application";
|
|
||||||
host.def(name, [detail](py::args, py::kwargs) -> py::object { throw std::runtime_error(detail); }, detail.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void PluginHost::RegisterBindings(py::module_& module)
|
|
||||||
{
|
|
||||||
auto host = module.def_submodule("host", "Host application API");
|
|
||||||
|
|
||||||
// Value types first so the docstring signatures of later registrars resolve to the bound names.
|
|
||||||
host_bindings::register_geometry(host);
|
|
||||||
host_bindings::register_mesh(host);
|
|
||||||
host_bindings::register_model(host);
|
|
||||||
host_bindings::register_slicing(host);
|
|
||||||
|
|
||||||
// These domains are backed by the live GUI, its Plater/MainFrame and the preset bundle.
|
|
||||||
add_unavailable_domain(host, "presets", "Preset bundle access (headless probe: unavailable)");
|
|
||||||
add_unavailable_domain(host, "app", "Live application and Plater access (headless probe: unavailable)");
|
|
||||||
add_unavailable_domain(host, "ui", "Native dialogs and HTML windows (headless probe: unavailable)");
|
|
||||||
for (const char* name : {"plater", "model", "preset_bundle", "app_info", "gl_info", "selected_printer"})
|
|
||||||
add_unavailable_function(host, name);
|
|
||||||
|
|
||||||
// Storage needs the PluginManager registry, which is not part of the headless probe.
|
|
||||||
auto plugin_host = host.def_submodule("plugin", "Plugin host API (headless probe: storage unavailable)");
|
|
||||||
plugin_host.def("storage", []() -> std::string {
|
|
||||||
throw std::runtime_error("orca.host.plugin.storage() is not available in the headless plugin permission probe");
|
|
||||||
}, "Return the installed folder of the current plugin.");
|
|
||||||
plugin_host.attr("__getattr__") = py::cpp_function([](const std::string& attribute) -> py::object {
|
|
||||||
throw std::runtime_error("orca.host.plugin." + attribute +
|
|
||||||
" is not available in the headless plugin permission probe");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Minimal replacement for the config API PythonPluginBridge binds into `orca`.
|
|
||||||
//
|
|
||||||
// The production implementation (PluginConfig.cpp) resolves presets, reads the sidebar's effective
|
|
||||||
// config and writes config.json through PluginManager, all of which pull in the GUI and the
|
|
||||||
// registry. The probe has neither: a capability reports an empty config, an empty version and a
|
|
||||||
// refused save. That keeps the bridge's Python surface intact without pretending to reproduce
|
|
||||||
// application state the probe does not have.
|
|
||||||
|
|
||||||
#include "slic3r/plugin/PluginConfig.hpp"
|
|
||||||
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace Slic3r {
|
|
||||||
|
|
||||||
nlohmann::json capability_get_config(const PluginCapabilityInterface&)
|
|
||||||
{
|
|
||||||
return nlohmann::json::object();
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string capability_get_config_version(const PluginCapabilityInterface&) { return {}; }
|
|
||||||
|
|
||||||
bool capability_save_config(const PluginCapabilityInterface&, const nlohmann::json&) { return false; }
|
|
||||||
|
|
||||||
std::string PluginConfig::capability_type_label(PluginCapabilityType type)
|
|
||||||
{
|
|
||||||
return plugin_capability_type_display_name(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace Slic3r
|
|
||||||
@@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests
|
|||||||
test_plugin_capabilities_in_use.cpp
|
test_plugin_capabilities_in_use.cpp
|
||||||
test_plugin_status.cpp
|
test_plugin_status.cpp
|
||||||
test_printer_agent.cpp
|
test_printer_agent.cpp
|
||||||
|
test_qidi_printer_agent.cpp
|
||||||
test_plugin_install.cpp
|
test_plugin_install.cpp
|
||||||
test_plugin_lifecycle.cpp
|
test_plugin_lifecycle.cpp
|
||||||
test_plugin_resource_texture.cpp
|
test_plugin_resource_texture.cpp
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public:
|
|||||||
mutable unsigned fetch_count = 0;
|
mutable unsigned fetch_count = 0;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||||
{
|
{
|
||||||
++fetch_count;
|
++fetch_count;
|
||||||
info.dev_name = "Test printer";
|
info.dev_name = "Test printer";
|
||||||
@@ -65,21 +65,32 @@ class StreamAgent : public MoonrakerPrinterAgent
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||||
using MoonrakerPrinterAgent::start_status_stream;
|
|
||||||
using MoonrakerPrinterAgent::stop_status_stream;
|
|
||||||
using MoonrakerPrinterAgent::ws_timings;
|
using MoonrakerPrinterAgent::ws_timings;
|
||||||
|
|
||||||
|
// Starts the stream the way a connection does, from a snapshot of the connection settings.
|
||||||
|
void start_stream(const std::string& base_url, bool use_ssl = false)
|
||||||
|
{
|
||||||
|
const std::string dev_id = "printer";
|
||||||
|
ConnectionSettings connection;
|
||||||
|
connection.dev_id = dev_id;
|
||||||
|
connection.base_url = base_url;
|
||||||
|
connection.use_ssl = use_ssl;
|
||||||
|
start_status_stream(dev_id, std::move(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_stream() { stop_status_stream(); }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
bool init_device_info(
|
bool init_device_info(const PrinterConnectionParams& params) override
|
||||||
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
|
|
||||||
{
|
{
|
||||||
device_info = MoonrakerDeviceInfo{};
|
device_info = MoonrakerDeviceInfo{};
|
||||||
device_info.dev_id = dev_id;
|
device_info.dev_id = params.dev_id;
|
||||||
device_info.base_url = "http://" + dev_ip + ":" + port;
|
device_info.dev_ip = params.host;
|
||||||
|
device_info.base_url = std::string(params.use_ssl ? "https://" : "http://") + params.host + ":" + params.port;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||||
{
|
{
|
||||||
info.dev_name = "Test printer";
|
info.dev_name = "Test printer";
|
||||||
return true;
|
return true;
|
||||||
@@ -97,6 +108,7 @@ enum class Behavior {
|
|||||||
IgnoresClose, // Reports Klippy disconnected, then never answers the close
|
IgnoresClose, // Reports Klippy disconnected, then never answers the close
|
||||||
StallsObjectList, // Never answers the stream's object list request
|
StallsObjectList, // Never answers the stream's object list request
|
||||||
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
|
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
|
||||||
|
RejectsTls, // Records the first byte of the stream, which a TLS client sends as a handshake
|
||||||
};
|
};
|
||||||
|
|
||||||
class FakePrinter
|
class FakePrinter
|
||||||
@@ -150,6 +162,19 @@ public:
|
|||||||
return m_close_frames;
|
return m_close_frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First byte the stream sent, or -1 until it has sent one.
|
||||||
|
bool wait_for_first_byte()
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, 10s, [&] { return m_first_byte >= 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
int first_byte()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
return m_first_byte;
|
||||||
|
}
|
||||||
|
|
||||||
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
|
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
|
||||||
bool wait_for_close_frames(int count)
|
bool wait_for_close_frames(int count)
|
||||||
{
|
{
|
||||||
@@ -189,6 +214,20 @@ private:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m_behavior == Behavior::RejectsTls) {
|
||||||
|
char first = 0;
|
||||||
|
beast::error_code ec;
|
||||||
|
socket.read_some(net::buffer(&first, 1), ec);
|
||||||
|
if (!ec) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
m_first_byte = static_cast<unsigned char>(first);
|
||||||
|
m_changed.notify_all();
|
||||||
|
}
|
||||||
|
mark_ready();
|
||||||
|
wait_for_release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
beast::flat_buffer buffer;
|
beast::flat_buffer buffer;
|
||||||
http::request<http::string_body> request;
|
http::request<http::string_body> request;
|
||||||
beast::error_code ec;
|
beast::error_code ec;
|
||||||
@@ -343,6 +382,7 @@ private:
|
|||||||
int m_http_requests = 0;
|
int m_http_requests = 0;
|
||||||
int m_http_hung_up = 0;
|
int m_http_hung_up = 0;
|
||||||
int m_close_frames = 0;
|
int m_close_frames = 0;
|
||||||
|
int m_first_byte = -1;
|
||||||
bool m_released = false;
|
bool m_released = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -385,12 +425,15 @@ struct StreamFixture
|
|||||||
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
|
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
|
||||||
~StreamFixture() { printer.release(); }
|
~StreamFixture() { printer.release(); }
|
||||||
|
|
||||||
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
|
void start() { agent.start_stream(printer.base_url()); }
|
||||||
|
|
||||||
|
// Runs the stream as a wss client; the printer must be served on the same port.
|
||||||
|
void start_secure() { agent.start_stream("https://" + printer.host() + ":" + printer.port(), true); }
|
||||||
|
|
||||||
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
|
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
|
||||||
bool stops_within(std::chrono::milliseconds limit)
|
bool stops_within(std::chrono::milliseconds limit)
|
||||||
{
|
{
|
||||||
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
|
auto stopped = std::async(std::launch::async, [this] { agent.stop_stream(); });
|
||||||
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
|
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
|
||||||
printer.release();
|
printer.release();
|
||||||
stopped.wait();
|
stopped.wait();
|
||||||
@@ -447,6 +490,16 @@ TEST_CASE("Stopping the status stream gives a silent printer no more than its ti
|
|||||||
CHECK(f.stops_within(limit));
|
CHECK(f.stops_within(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A secure status stream opens with a TLS handshake", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
StreamFixture f(Behavior::RejectsTls);
|
||||||
|
f.start_secure();
|
||||||
|
REQUIRE(f.printer.wait_for_first_byte());
|
||||||
|
// 0x16 is a TLS handshake record; a plaintext client would send "GET".
|
||||||
|
CHECK(f.printer.first_byte() == 0x16);
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
|
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
{
|
{
|
||||||
// A stop while the stream reads, and one while it waits on the object list request.
|
// A stop while the stream reads, and one while it waits on the object list request.
|
||||||
@@ -524,7 +577,7 @@ TEST_CASE("A callback on the stream thread can stop the status stream", "[Moonra
|
|||||||
StreamFixture f(Behavior::AnswersPings);
|
StreamFixture f(Behavior::AnswersPings);
|
||||||
f.agent.set_on_local_message_fn([&](std::string, std::string) {
|
f.agent.set_on_local_message_fn([&](std::string, std::string) {
|
||||||
std::call_once(stopped_once, [&] {
|
std::call_once(stopped_once, [&] {
|
||||||
f.agent.stop_status_stream();
|
f.agent.stop_stream();
|
||||||
stopped.set_value();
|
stopped.set_value();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -582,9 +635,12 @@ TEST_CASE("A disconnect during the connection queries keeps the status stream fr
|
|||||||
f.agent.connect_printer(params);
|
f.agent.connect_printer(params);
|
||||||
REQUIRE(f.printer.wait_for_http_requests(1));
|
REQUIRE(f.printer.wait_for_http_requests(1));
|
||||||
|
|
||||||
f.agent.disconnect_printer();
|
// disconnect_printer joins the worker running the stalled query, so it is called aside and the
|
||||||
|
// printer is released to let that query return.
|
||||||
|
auto disconnecting = std::async(std::launch::async, [&] { f.agent.disconnect_printer(); });
|
||||||
f.printer.release();
|
f.printer.release();
|
||||||
// The query has returned once the printer hangs up on it, and a stream would connect right after.
|
// The query has returned once the printer hangs up on it, and a stream would connect right after.
|
||||||
REQUIRE(f.printer.wait_for_http_hang_ups(1));
|
REQUIRE(f.printer.wait_for_http_hang_ups(1));
|
||||||
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
|
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
|
||||||
|
disconnecting.wait();
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,11 @@
|
|||||||
#include <boost/system/error_code.hpp>
|
#include <boost/system/error_code.hpp>
|
||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <libslic3r/Utils.hpp>
|
#include <libslic3r/Utils.hpp>
|
||||||
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
||||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||||
#include <slic3r/plugin/PluginManager.hpp>
|
#include <slic3r/plugin/PluginManager.hpp>
|
||||||
#include <slic3r/plugin/PluginProbeCollector.hpp>
|
|
||||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
@@ -34,7 +32,6 @@
|
|||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <pybind11/stl.h>
|
#include <pybind11/stl.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <ios>
|
#include <ios>
|
||||||
@@ -70,29 +67,9 @@ void seed_denied_keywords()
|
|||||||
mgr.add_denied_path_keyword(keyword);
|
mgr.add_denied_path_keyword(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installs the record-only observer for a test and always clears it, so a failed CHECK cannot
|
|
||||||
// leave a callback pointing at a destroyed collector for the next test in this process.
|
|
||||||
struct ScopedTestObservationObserver
|
|
||||||
{
|
|
||||||
explicit ScopedTestObservationObserver(PluginProbeCollector& collector)
|
|
||||||
: m_collector(collector)
|
|
||||||
{
|
|
||||||
PluginAuditManager::instance().set_observation_observer(
|
|
||||||
[this](const AuditObservation& observation) { m_collector.record(observation); });
|
|
||||||
}
|
|
||||||
|
|
||||||
~ScopedTestObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); }
|
|
||||||
|
|
||||||
ScopedTestObservationObserver(const ScopedTestObservationObserver&) = delete;
|
|
||||||
ScopedTestObservationObserver& operator=(const ScopedTestObservationObserver&) = delete;
|
|
||||||
|
|
||||||
private:
|
|
||||||
PluginProbeCollector& m_collector;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
|
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
|
||||||
{
|
{
|
||||||
seed_denied_names();
|
seed_denied_names();
|
||||||
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||||
@@ -104,7 +81,6 @@ TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
|
|||||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
|
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
|
||||||
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
|
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
|
||||||
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
|
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
|
||||||
CHECK(mgr.is_denied_filename(fs::path(".install_state.json")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("companions holding the same secrets are denied by the prefix rule")
|
SECTION("companions holding the same secrets are denied by the prefix rule")
|
||||||
@@ -160,7 +136,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
|
|||||||
|
|
||||||
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
|
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
|
||||||
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
|
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
|
||||||
const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json";
|
|
||||||
|
|
||||||
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
|
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
|
||||||
{
|
{
|
||||||
@@ -201,17 +176,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
|
|||||||
CHECK_FALSE(decision.allowed);
|
CHECK_FALSE(decision.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTION("plugin install state is blocked for reads and writes despite the allowed root")
|
|
||||||
{
|
|
||||||
AuditDecision read_decision = mgr.check_open(install_state.string(), "r");
|
|
||||||
CHECK_FALSE(read_decision.allowed);
|
|
||||||
CHECK(read_decision.reason == "denied filename");
|
|
||||||
|
|
||||||
AuditDecision write_decision = mgr.check_open(install_state.string(), "w");
|
|
||||||
CHECK_FALSE(write_decision.allowed);
|
|
||||||
CHECK(write_decision.reason == "denied filename");
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("a traversal path resolving to the config is blocked")
|
SECTION("a traversal path resolving to the config is blocked")
|
||||||
{
|
{
|
||||||
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
|
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
|
||||||
@@ -690,71 +654,6 @@ const char* const PLUGIN = "AuditPlugin";
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("Probe phase scope restores the previous phase", "[audit]")
|
|
||||||
{
|
|
||||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
|
||||||
mgr.set_current_audit_phase("outer");
|
|
||||||
{
|
|
||||||
ScopedPluginAuditPhase inner("inner");
|
|
||||||
CHECK(mgr.current_audit_phase() == "inner");
|
|
||||||
}
|
|
||||||
CHECK(mgr.current_audit_phase() == "outer");
|
|
||||||
mgr.clear_current_audit_phase();
|
|
||||||
CHECK(mgr.current_audit_phase().empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Record-only probe observations bypass prompts and retain phase attribution", "[audit][Python]")
|
|
||||||
{
|
|
||||||
AuditHarness harness;
|
|
||||||
if (!harness.initialized)
|
|
||||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
||||||
harness.add_plugin(PLUGIN);
|
|
||||||
py::gil_scoped_acquire gil;
|
|
||||||
PythonSide python(harness);
|
|
||||||
|
|
||||||
PluginProbeCollector collector;
|
|
||||||
ScopedTestObservationObserver observer(collector);
|
|
||||||
|
|
||||||
// A declared read permission is recorded and allowed, but must not be written to the sidecar.
|
|
||||||
{
|
|
||||||
ScopedPluginAuditContext context(PLUGIN);
|
|
||||||
CHECK(PluginAuditManager::instance().request_filesystem_read_permissions(PLUGIN, {"/tmp/probe-declared"}));
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
ScopedPluginAuditPhase phase("probe:callback");
|
|
||||||
python.run_as(PLUGIN, "outcome = attempt(lambda: open(PRIVATE).close())");
|
|
||||||
}
|
|
||||||
|
|
||||||
CHECK(python.str("outcome") == "ok");
|
|
||||||
CHECK(harness.prompts.empty());
|
|
||||||
CHECK(harness.saved_permissions(PLUGIN).fs_read.empty());
|
|
||||||
|
|
||||||
const std::vector<AuditObservation> observations = collector.observations();
|
|
||||||
const auto open_event = std::find_if(
|
|
||||||
observations.begin(), observations.end(), [](const AuditObservation& observation) {
|
|
||||||
return observation.event_name == "open";
|
|
||||||
});
|
|
||||||
REQUIRE(open_event != observations.end());
|
|
||||||
CHECK(open_event->plugin_key == PLUGIN);
|
|
||||||
CHECK(open_event->phase == "probe:callback");
|
|
||||||
CHECK(open_event->category == "FsRead");
|
|
||||||
CHECK(open_event->continued);
|
|
||||||
CHECK(std::find(open_event->targets.begin(), open_event->targets.end(), python.str("PRIVATE")) !=
|
|
||||||
open_event->targets.end());
|
|
||||||
|
|
||||||
const auto declared = std::find_if(observations.begin(), observations.end(), [](const AuditObservation& observation) {
|
|
||||||
return observation.event_name == "orca.request_permissions";
|
|
||||||
});
|
|
||||||
REQUIRE(declared != observations.end());
|
|
||||||
CHECK(declared->category == "FsRead");
|
|
||||||
CHECK(std::find(declared->targets.begin(), declared->targets.end(), "/tmp/probe-declared") != declared->targets.end());
|
|
||||||
|
|
||||||
const nlohmann::json summary = collector.summary();
|
|
||||||
CHECK(summary["categories"].contains("FsRead"));
|
|
||||||
CHECK(summary["observed_event_count"].get<std::size_t>() == observations.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
|
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
|
||||||
{
|
{
|
||||||
AuditHarness harness;
|
AuditHarness harness;
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <slic3r/Utils/BBLPrinterAgent.hpp>
|
||||||
|
#include <slic3r/Utils/IPrinterAgent.hpp>
|
||||||
|
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <slic3r/Utils/NetworkAgentFactory.hpp>
|
#include <slic3r/Utils/NetworkAgentFactory.hpp>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <catch2/catch_message.hpp>
|
#include <catch2/catch_message.hpp>
|
||||||
|
#include "catch2/matchers/catch_matchers.hpp"
|
||||||
|
#include "catch2/matchers/catch_matchers_floating_point.hpp"
|
||||||
#include "python_test_support.hpp"
|
#include "python_test_support.hpp"
|
||||||
|
|
||||||
#include <pybind11/embed.h>
|
#include <pybind11/embed.h>
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <future>
|
||||||
|
#include <slic3r/Utils/bambu_networking.hpp>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
#include <pybind11/cast.h>
|
#include <pybind11/cast.h>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace Slic3r { class ICloudServiceAgent; }
|
namespace Slic3r { class ICloudServiceAgent; }
|
||||||
namespace Slic3r { class IPrinterAgent; }
|
namespace Slic3r { class IPrinterAgent; }
|
||||||
@@ -20,12 +32,390 @@ namespace Slic3r { class IPrinterAgent; }
|
|||||||
using namespace Slic3r;
|
using namespace Slic3r;
|
||||||
namespace py = pybind11;
|
namespace py = pybind11;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Releases a promise on scope exit, so a throwing REQUIRE cannot leave a parked detached
|
||||||
|
// thread (and any destructor that joins it) blocked forever.
|
||||||
|
class ScopedPromiseRelease
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit ScopedPromiseRelease(std::shared_ptr<std::promise<void>> p) : m_p(std::move(p)) {}
|
||||||
|
~ScopedPromiseRelease()
|
||||||
|
{
|
||||||
|
if (m_p) {
|
||||||
|
try {
|
||||||
|
m_p->set_value();
|
||||||
|
} catch (...) {
|
||||||
|
// promise already satisfied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScopedPromiseRelease(const ScopedPromiseRelease&) = delete;
|
||||||
|
ScopedPromiseRelease& operator=(const ScopedPromiseRelease&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::shared_ptr<std::promise<void>> m_p;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class MoonrakerParserProbe : public MoonrakerPrinterAgent
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
using MoonrakerPrinterAgent::parse_nozzle_diameter;
|
||||||
|
|
||||||
|
explicit MoonrakerParserProbe(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
const auto response = nlohmann::json::parse(R"({
|
||||||
|
"result": {
|
||||||
|
"status": {
|
||||||
|
"configfile": {
|
||||||
|
"settings": {
|
||||||
|
"extruder": {
|
||||||
|
"nozzle_diameter": 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})");
|
||||||
|
|
||||||
|
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(response), Catch::Matchers::WithinAbs(0.6f, 1e-4f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
const auto raw_config_response = nlohmann::json::parse(R"({
|
||||||
|
"result": {
|
||||||
|
"status": {
|
||||||
|
"configfile": {
|
||||||
|
"config": {
|
||||||
|
"extruder": {
|
||||||
|
"nozzle_diameter": "0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})");
|
||||||
|
const auto missing_response = nlohmann::json::object();
|
||||||
|
|
||||||
|
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response), Catch::Matchers::WithinAbs(0.8f, 1e-4f));
|
||||||
|
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(missing_response), Catch::Matchers::WithinAbs(0.0f, 1e-6f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
|
||||||
|
TEST_CASE("default AMS commands report not supported", "[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
MoonrakerPrinterAgent agent("");
|
||||||
|
|
||||||
|
CHECK(agent.command_ams_refresh_rfid("dev", 123, 1, 0, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
|
||||||
|
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
|
||||||
|
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker light name matching", "[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
CHECK(moonraker_is_light_name("caselight"));
|
||||||
|
CHECK(moonraker_is_light_name("LED_STRIP"));
|
||||||
|
CHECK_FALSE(moonraker_is_light_name("beeper"));
|
||||||
|
CHECK(moonraker_is_light_name("FLASHLIGHT_SWITCH"));
|
||||||
|
CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker webcam selection skips disabled webcams and prefers the first enabled one",
|
||||||
|
"[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
const auto response = nlohmann::json::parse(R"({
|
||||||
|
"result": { "webcams": [
|
||||||
|
{ "name": "disabled", "enabled": false, "stream_url": "http://192.168.1.9:8080/stream" },
|
||||||
|
{ "name": "enabled", "enabled": true, "stream_url": "http://192.168.1.9:8080/stream" }
|
||||||
|
]}
|
||||||
|
})");
|
||||||
|
|
||||||
|
MoonrakerWebcamSelection selection;
|
||||||
|
REQUIRE(moonraker_parse_webcam_list(response, "http://192.168.1.9:7125", selection));
|
||||||
|
CHECK(selection.name == "enabled");
|
||||||
|
CHECK(selection.url == "http://192.168.1.9:8080/stream");
|
||||||
|
CHECK(selection.mode == CameraStreamMode::http);
|
||||||
|
CHECK(selection.error.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker webcam selection resolves relative URLs, maps rtsp, and rejects other schemes",
|
||||||
|
"[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
const auto relative = nlohmann::json::parse(R"({
|
||||||
|
"result": { "webcams": [ { "name": "cam", "snapshot_url": "/webcam/?action=snapshot" } ] }
|
||||||
|
})");
|
||||||
|
MoonrakerWebcamSelection rel;
|
||||||
|
REQUIRE(moonraker_parse_webcam_list(relative, "http://192.168.1.9:7125", rel));
|
||||||
|
// Relative URLs use the printer web root, without the Moonraker API port.
|
||||||
|
CHECK(rel.url == "http://192.168.1.9/webcam/?action=snapshot");
|
||||||
|
CHECK(rel.mode == CameraStreamMode::http_snapshot);
|
||||||
|
|
||||||
|
const auto rtsp = nlohmann::json::parse(R"({
|
||||||
|
"result": { "webcams": [ { "name": "cam", "stream_url": "rtsp://192.168.1.9:554/live" } ] }
|
||||||
|
})");
|
||||||
|
MoonrakerWebcamSelection rt;
|
||||||
|
REQUIRE(moonraker_parse_webcam_list(rtsp, "http://192.168.1.9:7125", rt));
|
||||||
|
CHECK(rt.mode == CameraStreamMode::rtsp);
|
||||||
|
|
||||||
|
const auto unsupported = nlohmann::json::parse(R"({
|
||||||
|
"result": { "webcams": [ { "name": "cam", "stream_url": "weird://host/x" } ] }
|
||||||
|
})");
|
||||||
|
MoonrakerWebcamSelection bad;
|
||||||
|
CHECK_FALSE(moonraker_parse_webcam_list(unsupported, "http://192.168.1.9:7125", bad));
|
||||||
|
CHECK(bad.error == "Unsupported webcam URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker webcam selection reports no webcam and malformed structure", "[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
const auto empty = nlohmann::json::parse(R"({ "result": { "webcams": [] } })");
|
||||||
|
MoonrakerWebcamSelection none;
|
||||||
|
CHECK_FALSE(moonraker_parse_webcam_list(empty, "http://host:7125", none));
|
||||||
|
CHECK(none.error == "No enabled webcam");
|
||||||
|
|
||||||
|
const auto disabled_only = nlohmann::json::parse(R"({
|
||||||
|
"result": { "webcams": [ { "name": "disabled", "enabled": false, "stream_url": "http://host/stream" } ] }
|
||||||
|
})");
|
||||||
|
MoonrakerWebcamSelection off;
|
||||||
|
CHECK_FALSE(moonraker_parse_webcam_list(disabled_only, "http://host:7125", off));
|
||||||
|
|
||||||
|
const auto malformed = nlohmann::json::parse(R"({ "result": { "nope": 1 } })");
|
||||||
|
MoonrakerWebcamSelection shape;
|
||||||
|
CHECK_FALSE(moonraker_parse_webcam_list(malformed, "http://host:7125", shape));
|
||||||
|
CHECK(shape.error == "Unexpected JSON structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// UNIT - handle_request's not-supported default.
|
||||||
|
// The agent is the only thing that knows what it can translate, so an untranslated
|
||||||
|
// command has to say so instead of returning success and letting the UI believe the
|
||||||
|
// control worked. Guards the inverse too: the pushing namespace is genuinely
|
||||||
|
// satisfied by the websocket status stream, and it re-fires from the keepalive timer
|
||||||
|
// roughly once a second, so it must stay a success or it would raise a dialog on a
|
||||||
|
// timer. Only branches that touch neither the network nor wx are exercised.
|
||||||
|
// ===========================================================================
|
||||||
|
TEST_CASE("Moonraker reports untranslated commands as not supported", "[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
MoonrakerPrinterAgent agent("");
|
||||||
|
|
||||||
|
CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) ==
|
||||||
|
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
|
||||||
|
CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) ==
|
||||||
|
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
|
||||||
|
CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) ==
|
||||||
|
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
|
||||||
|
|
||||||
|
CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
|
||||||
|
CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
|
||||||
|
|
||||||
|
// why: malformed input is a different failure than an untranslated command, and the
|
||||||
|
// default must not swallow it into a misleading not-supported verdict.
|
||||||
|
CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// why: IPrinterAgent::fetch_filament_info is the single virtual hook derived agents override.
|
||||||
|
// Pull-mode overrides must be synchronous (the caller reads DevFilaSystem as soon as it
|
||||||
|
// returns), but subscription overrides may be fire-and-forget: QidiPrinterAgent/Snapmaker
|
||||||
|
// spawn a detached thread when asked for subscription updates. QidiPrinterAgent is `final`,
|
||||||
|
// so this probes the subscription contract with a controllable double instead.
|
||||||
|
TEST_CASE("a fire-and-forget subscription fetch is not waited on by the caller",
|
||||||
|
"[MoonrakerPrinterAgent]")
|
||||||
|
{
|
||||||
|
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit RecordingAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
|
||||||
|
|
||||||
|
// Shared so the detached proxy fetch never touches `this`: a throwing REQUIRE
|
||||||
|
// then cannot leave it dereferencing a destroyed agent.
|
||||||
|
std::shared_ptr<std::atomic<bool>> invoked{std::make_shared<std::atomic<bool>>(false)};
|
||||||
|
std::shared_ptr<std::promise<void>> release_gate{std::make_shared<std::promise<void>>()};
|
||||||
|
std::shared_ptr<std::promise<void>> done_promise{std::make_shared<std::promise<void>>()};
|
||||||
|
|
||||||
|
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override
|
||||||
|
{
|
||||||
|
// Only the subscription path is allowed to be fire-and-forget.
|
||||||
|
if (sync_mode != FilamentSyncMode::subscription)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
auto invoked_p = invoked;
|
||||||
|
auto release_gate_p = release_gate;
|
||||||
|
auto done_promise_p = done_promise;
|
||||||
|
std::thread([invoked_p, release_gate_p, done_promise_p]() {
|
||||||
|
invoked_p->store(true);
|
||||||
|
// Block here until the test explicitly releases us, proving the caller
|
||||||
|
// (fetch_filament_info) does not wait for this to run.
|
||||||
|
release_gate_p->get_future().wait();
|
||||||
|
done_promise_p->set_value();
|
||||||
|
}).detach();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto agent = std::make_shared<RecordingAgent>(std::string{});
|
||||||
|
auto done_future = agent->done_promise->get_future();
|
||||||
|
ScopedPromiseRelease release_gate_guard{agent->release_gate};
|
||||||
|
|
||||||
|
bool immediate_result = agent->fetch_filament_info("test-dev", FilamentSyncMode::subscription);
|
||||||
|
|
||||||
|
// fetch_filament_info must return before its background work completes — prove
|
||||||
|
// it by confirming the background call is still blocked on the gate right now.
|
||||||
|
REQUIRE(immediate_result == true);
|
||||||
|
REQUIRE(done_future.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout);
|
||||||
|
|
||||||
|
// Now let the background call finish and confirm it actually ran (polymorphic dispatch).
|
||||||
|
agent->release_gate->set_value();
|
||||||
|
REQUIRE(done_future.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
|
||||||
|
REQUIRE(agent->invoked->load() == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Globals so a parked proxy fetch thread never dereferences a freed agent.
|
||||||
|
std::atomic<int> g_deferred_fetch_running{0};
|
||||||
|
std::atomic<bool> g_deferred_destroy_returned{false};
|
||||||
|
|
||||||
|
// Releases the given gates, then joins on scope exit: so a throwing REQUIRE cannot leave
|
||||||
|
// the thread blocked (deadlocking the join) or let it std::terminate.
|
||||||
|
class ScopedJoiner
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ScopedJoiner(std::thread& t, std::shared_ptr<std::promise<void>> gate1, std::shared_ptr<std::promise<void>> gate2)
|
||||||
|
: m_thread(t), m_gates{std::move(gate1), std::move(gate2)}
|
||||||
|
{}
|
||||||
|
~ScopedJoiner()
|
||||||
|
{
|
||||||
|
for (auto& gate : m_gates) {
|
||||||
|
if (gate) {
|
||||||
|
try {
|
||||||
|
gate->set_value();
|
||||||
|
} catch (...) {
|
||||||
|
// promise already satisfied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m_thread.joinable()) m_thread.join();
|
||||||
|
}
|
||||||
|
ScopedJoiner(const ScopedJoiner&) = delete;
|
||||||
|
ScopedJoiner& operator=(const ScopedJoiner&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::thread& m_thread;
|
||||||
|
std::shared_ptr<std::promise<void>> m_gates[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
// A fetch that parks before touching the in-flight counter, so teardown's wait can
|
||||||
|
// observe zero first.
|
||||||
|
class DeferredFetchAgent : public MoonrakerPrinterAgent
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit DeferredFetchAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
|
||||||
|
|
||||||
|
// Shared so a parked proxy fetch can never outlive the stack that owns it.
|
||||||
|
std::shared_ptr<std::promise<void>> entered{std::make_shared<std::promise<void>>()};
|
||||||
|
std::shared_ptr<std::promise<void>> allow_fetch{std::make_shared<std::promise<void>>()};
|
||||||
|
std::shared_ptr<std::promise<void>> allow_finish{std::make_shared<std::promise<void>>()};
|
||||||
|
std::shared_ptr<std::promise<void>> running{std::make_shared<std::promise<void>>()};
|
||||||
|
|
||||||
|
// Runs the callable on the command worker, which teardown joins.
|
||||||
|
void post(std::function<void()> fn) { enqueue_command(std::move(fn)); }
|
||||||
|
|
||||||
|
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode /*sync_mode*/ = FilamentSyncMode::pull) override
|
||||||
|
{
|
||||||
|
// Resumes after ~DeferredFetchAgent destroyed these members; snapshot up front.
|
||||||
|
auto entered_p = entered;
|
||||||
|
auto allow_fetch_p = allow_fetch;
|
||||||
|
auto allow_finish_p = allow_finish;
|
||||||
|
auto running_p = running;
|
||||||
|
|
||||||
|
entered_p->set_value();
|
||||||
|
allow_fetch_p->get_future().wait();
|
||||||
|
|
||||||
|
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
std::thread([this, finish = std::move(allow_finish_p), running = std::move(running_p)] {
|
||||||
|
struct InFlightGuard
|
||||||
|
{
|
||||||
|
MoonrakerPrinterAgent& owner;
|
||||||
|
~InFlightGuard() { owner.release_fetch_slot(); }
|
||||||
|
} guard{*this};
|
||||||
|
|
||||||
|
g_deferred_fetch_running.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
running->set_value();
|
||||||
|
finish->get_future().wait();
|
||||||
|
g_deferred_fetch_running.fetch_sub(1, std::memory_order_relaxed);
|
||||||
|
}).detach();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// REGRESSION - teardown must not return while a fetch it started is in flight.
|
||||||
|
// The command worker parks a fetch before it reserves the in-flight slot, forcing
|
||||||
|
// the "wait already observed zero" interleaving deterministically.
|
||||||
|
TEST_CASE("an agent's destruction waits for a fetch started by its worker during teardown",
|
||||||
|
"[MoonrakerPrinterAgent][Regression]")
|
||||||
|
{
|
||||||
|
g_deferred_fetch_running.store(0);
|
||||||
|
g_deferred_destroy_returned.store(false);
|
||||||
|
|
||||||
|
auto agent = std::make_shared<DeferredFetchAgent>(std::string{});
|
||||||
|
auto entered = agent->entered;
|
||||||
|
auto allow_fetch = agent->allow_fetch;
|
||||||
|
auto allow_finish = agent->allow_finish;
|
||||||
|
auto running = agent->running;
|
||||||
|
|
||||||
|
// Safety net for the pre-destroyer failure paths: release both gates before the
|
||||||
|
// agent is destroyed (declared after it, so destroyed before it).
|
||||||
|
ScopedPromiseRelease release_finish{allow_finish};
|
||||||
|
ScopedPromiseRelease release_fetch{allow_fetch};
|
||||||
|
|
||||||
|
// Park a fetch inside the command worker while the agent is still complete.
|
||||||
|
agent->post([ptr = agent.get()] { ptr->fetch_filament_info("dev", FilamentSyncMode::pull); });
|
||||||
|
REQUIRE(entered->get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready);
|
||||||
|
|
||||||
|
// Destroy on another thread so this one can drive the parked fetch.
|
||||||
|
std::thread destroyer([owned = std::move(agent)]() mutable {
|
||||||
|
owned.reset();
|
||||||
|
g_deferred_destroy_returned.store(true);
|
||||||
|
});
|
||||||
|
// Releases both gates before joining, so a failing REQUIRE cannot deadlock the join.
|
||||||
|
ScopedJoiner join_destroyer{destroyer, allow_fetch, allow_finish};
|
||||||
|
|
||||||
|
// Let the worker reserve the in-flight slot and spawn its fetch, then wait until it
|
||||||
|
// is genuinely parked (no polling).
|
||||||
|
allow_fetch->set_value();
|
||||||
|
REQUIRE(running->get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready);
|
||||||
|
REQUIRE(g_deferred_fetch_running.load() == 1);
|
||||||
|
|
||||||
|
// A correct teardown cannot return while the fetch is parked; give a buggy one time.
|
||||||
|
for (int i = 0; i < 200 && !g_deferred_destroy_returned.load(); ++i)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
|
||||||
|
if (g_deferred_destroy_returned.load()) {
|
||||||
|
// Bug: teardown returned with a fetch still running. Don't release allow_finish.
|
||||||
|
CHECK(g_deferred_fetch_running.load() == 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed order: destruction is still blocked on the in-flight fetch.
|
||||||
|
allow_finish->set_value();
|
||||||
|
destroyer.join();
|
||||||
|
CHECK(g_deferred_destroy_returned.load());
|
||||||
|
CHECK(g_deferred_fetch_running.load() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// UNIT - printer-agent registry duplicate handling.
|
// UNIT - printer-agent registry duplicate handling.
|
||||||
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
|
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
|
||||||
// or previously registered agent.
|
// or previously registered agent.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
|
TEST_CASE("printer-agent registry register / lookup / duplicate-reject", "[PrinterAgent]")
|
||||||
{
|
{
|
||||||
// why: the registry is process-global state shared by the test binary, and
|
// why: the registry is process-global state shared by the test binary, and
|
||||||
// Catch2 may run cases in any order. Use an id that cannot collide with
|
// Catch2 may run cases in any order. Use an id that cannot collide with
|
||||||
@@ -69,7 +459,7 @@ TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "
|
|||||||
// fails to load the codecs needed for the filesystem encoding. The shared helper
|
// fails to load the codecs needed for the filesystem encoding. The shared helper
|
||||||
// points PyConfig.home at the python/ runtime staged next to the test executable.
|
// points PyConfig.home at the python/ runtime staged next to the test executable.
|
||||||
|
|
||||||
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
|
TEST_CASE("orca.printer_agent binding surface", "[integration][Python]")
|
||||||
{
|
{
|
||||||
py::module_ orca = import_orca_module();
|
py::module_ orca = import_orca_module();
|
||||||
|
|
||||||
@@ -120,7 +510,7 @@ TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Pyth
|
|||||||
// them in the lightweight embedded-interpreter test catches binding breakage
|
// them in the lightweight embedded-interpreter test catches binding breakage
|
||||||
// before the plugin-loader test needs to run.
|
// before the plugin-loader test needs to run.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
|
TEST_CASE("orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
|
||||||
{
|
{
|
||||||
py::module_ orca = import_orca_module();
|
py::module_ orca = import_orca_module();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#include "catch2/catch_test_macros.hpp"
|
||||||
|
#include "catch2/matchers/catch_matchers.hpp"
|
||||||
|
#include "catch2/matchers/catch_matchers_string.hpp"
|
||||||
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <slic3r/Utils/QidiPrinterAgent.hpp>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace Slic3r;
|
||||||
|
|
||||||
|
TEST_CASE("Qidi slot response rejects null variables without throwing", "[QidiPrinterAgent]")
|
||||||
|
{
|
||||||
|
const std::string response = R"({
|
||||||
|
"result": {
|
||||||
|
"status": {
|
||||||
|
"save_variables": {
|
||||||
|
"variables": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})";
|
||||||
|
nlohmann::json status;
|
||||||
|
nlohmann::json variables;
|
||||||
|
std::string error;
|
||||||
|
bool parsed = true;
|
||||||
|
|
||||||
|
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
|
||||||
|
CHECK_FALSE(parsed);
|
||||||
|
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("variables"));
|
||||||
|
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Qidi slot response rejects missing and non-object fields without throwing", "[QidiPrinterAgent]")
|
||||||
|
{
|
||||||
|
std::string response;
|
||||||
|
|
||||||
|
SECTION("missing result")
|
||||||
|
{
|
||||||
|
response = R"({})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("non-object result")
|
||||||
|
{
|
||||||
|
response = R"({"result":null})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("missing status")
|
||||||
|
{
|
||||||
|
response = R"({"result":{}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("non-object status")
|
||||||
|
{
|
||||||
|
response = R"({"result":{"status":null}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("missing save_variables")
|
||||||
|
{
|
||||||
|
response = R"({"result":{"status":{}}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("non-object save_variables")
|
||||||
|
{
|
||||||
|
response = R"({"result":{"status":{"save_variables":null}}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("missing variables")
|
||||||
|
{
|
||||||
|
response = R"({"result":{"status":{"save_variables":{}}}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("scalar")
|
||||||
|
{
|
||||||
|
response = R"({"result":{"status":{"save_variables":{"variables":42}}}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("array")
|
||||||
|
{
|
||||||
|
response = R"({"result":{"status":{"save_variables":{"variables":[]}}}})";
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json status;
|
||||||
|
nlohmann::json variables;
|
||||||
|
std::string error;
|
||||||
|
bool parsed = true;
|
||||||
|
|
||||||
|
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
|
||||||
|
CHECK_FALSE(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Qidi slot response exposes valid status and variables", "[QidiPrinterAgent]")
|
||||||
|
{
|
||||||
|
const std::string response = R"({
|
||||||
|
"result": {
|
||||||
|
"status": {
|
||||||
|
"save_variables": {
|
||||||
|
"variables": {
|
||||||
|
"box_count": 2,
|
||||||
|
"color_slot0": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"box_stepper slot0": {
|
||||||
|
"runout_button": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})";
|
||||||
|
nlohmann::json status;
|
||||||
|
nlohmann::json variables;
|
||||||
|
std::string error;
|
||||||
|
bool parsed = false;
|
||||||
|
|
||||||
|
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
|
||||||
|
REQUIRE(parsed);
|
||||||
|
CHECK(status.is_object());
|
||||||
|
CHECK(variables.is_object());
|
||||||
|
CHECK(variables.at("box_count") == 2);
|
||||||
|
CHECK(status.contains("box_stepper slot0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Qidi slot response rejects invalid JSON", "[QidiPrinterAgent]")
|
||||||
|
{
|
||||||
|
nlohmann::json status;
|
||||||
|
nlohmann::json variables;
|
||||||
|
std::string error;
|
||||||
|
bool parsed = true;
|
||||||
|
|
||||||
|
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response("{not json", status, variables, error));
|
||||||
|
CHECK_FALSE(parsed);
|
||||||
|
CHECK(error == "Invalid JSON response");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user