Compare commits

...
Author SHA1 Message Date
Hanif Koh 273ee2baef Lock Config and Preset Files Across Instances and Write Them Atomically
Every running instance shares one OrcaSlicer.conf and one user preset
tree, and nothing kept their writers apart. Two instances saving at the
same moment, or the cloud preset sync thread writing while the GUI thread
saved, could interleave, and a reader in another instance could open a
preset JSON or .info file between truncate and close and get a partial
file, dropping that preset for the session with a parse error.

Add InstanceLock, a scoped guard that serialises the threads of one
process through a recursive mutex and other processes through an advisory
OS file lock: flock on POSIX, held on the guard's own descriptor so no
other close in the process can drop it, and LockFileEx on Windows. The
outermost guard opens the lock file and closes it on release, so nothing
stays open between saves and a data dir can be removed once nothing is
saving into it; the file itself is kept, since deleting it would let a
third instance lock a fresh file while the second still holds the old
one. It is best effort: when the lock file cannot be opened or locked, or
another instance still holds it after a second, the guard logs once and
lets the write proceed, then leaves the file alone for ten seconds, so
a hung instance never blocks every other one and a holder stuck in a
debugger does not cost a stall per save. The guard sits at the leaf
readers and writers: set_sync_info_and_save() calls save_info() under
the preset collection mutex, so a batch lock around save_user_presets()
would invert the order against the sync thread. The preset scan takes
the guard per file rather than across the scan, so a save never waits
for the whole scan. Read-only scans, which is what the CLI does, take no
lock and create no lock file.

AppConfig holds OrcaSlicer.conf.lock in load() and save(); load is
included because the Windows path restores from the .bak copy. Every
user preset writer and reader holds user.lock: Preset::save(), which
writes no .info when the preset itself could not be written, since an
.info without its preset reads as a cloud deletion request, save_info(),
reload() and remove_files(), each file read by the preset scan, the
bundle metadata reads and write, the .info removal after a cloud-confirmed
delete, the orphaned-.info scan on the sync thread, the bundle folder
removal on unsubscribe and the physical printer writers and delete
paths. A bundle import extracts under cache/ into a folder per process
and per import, where no scan reads.

Preset JSON, .info, bundle metadata, physical printer and config files,
and the caches and state files that already used a temporary by hand,
now go through write_file_atomically(), which writes <file>.<pid>.<n>.tmp
beside the target and renames it over, so a reader that never waits sees
a complete old or new file. A symlink is followed; a target that is not
a regular file is written in place; and when no temporary can be created
beside an existing target, or the rename itself is refused, by a Windows
reader holding the file open or a mount that cannot replace in one step,
the helper writes in place as before, since losing the save is worse
than a torn read. On POSIX the rename replaces the
target atomically where the old code removed it first and left a window
with no file at all; only a mount that refuses a one-step replace gets
the old remove-then-rename. A crash between temporary and rename leaves
the temporary behind, which no scan reads. A failed config write keeps
the config dirty, and the idle handler waits ten seconds before retrying
while an explicit save always tries.
2026-09-25 10:19:33 +08:00
SoftFever 811b587eb0 Document filament color as a runtime property, not a preset
Add the one-all-printer-preset-per-product rule to the orca-profiles skill:
color is chosen at runtime, a material family is a new product and a color is
not, and CI does not catch per-color presets so it stays a review call. Note
that @System is the all-printer convention rather than an enforced check.
2026-09-22 14:55:08 +08:00
HanifKoh 2876374b45 Add a Dockable HTML Panel API for Plugins (#15736)
orca.host.ui.create_dock_panel(html, title, width, height, on_message,
on_close, dock) hosts plugin HTML in a pane of the Plater's dock manager,
next to the sidebar, and returns a UiDockPanel handle
(post/show/hide/close/is_open). The arguments follow create_window(). The
panel uses the window.orca bridge of plugin windows, restores its position
and size from the saved window layout, hides with the Plater off the Prepare
and Preview tabs when floating, and is closed with its plugin; plugin panes
are removed in MainFrame::shutdown().

The web view hosting moves out of PluginPage into a shared WebPanel base:
bootstrap page and swap to the plugin HTML, theme, element-default and
bridge scripts, window.orca message parsing, delivery to the page, and live
re-theming, also re-applied on every load after the swap. Pages tabs and
docked panels both derive from it. Pages tabs now re-theme in place on a
theme change instead of being reloaded, and a window.orca call a host does
not support is logged.

What the hosts share no longer lives in one of them: the bootstrap page, the
base URL and the plugin-window bridge move to Widgets/WebHosting, used by
WebDialog and WebPanel alike. The Plater restores plugin panes with a new
saved-layout parser, GUI/AuiPaneLayout, kept in its own small header so
slic3rutils can test it without pulling in the Plater.

The web hosting classes carry no plugin name, so other hosts can reuse them:
PluginWebDialog becomes WebDialog (its bootstrap page moves to
resources/web/dialog/WebDialog), and destroy_for_plugin(),
load_plugin_content() and plugin_defaults_user_script() become
destroy_silently(), load_page_html() and element_defaults_user_script().

Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and
binding and layout-helper tests in slic3rutils.
2026-09-22 14:52:25 +08:00
47 changed files with 1944 additions and 481 deletions
+4
View File
@@ -58,6 +58,9 @@ Paths below are relative to this skill. Commands run from the repository root.
9. **Run the full profile checks before reporting completion.** A vendor-scoped pass is only a 9. **Run the full profile checks before reporting completion.** A vendor-scoped pass is only a
development loop. Review also covers version bumps, assets, non-default processes and hardware development loop. Review also covers version bumps, assets, non-default processes and hardware
tuning that CI cannot establish. tuning that CI cannot establish.
10. **One all-printer preset per product; color is a runtime property, never a preset.** Never ship
presets that differ only by color — CI accepts them, so this is a review call. See
[color is a runtime property](references/filament-profiles.md#color-is-a-runtime-property).
## Creating or modifying a profile ## Creating or modifying a profile
@@ -107,6 +110,7 @@ Paths below are relative to this skill. Commands run from the repository root.
| A setting has no effect | Key spelling/type, `handle_legacy`, or a config key placed on a `machine_model` | | A setting has no effect | Key spelling/type, `handle_legacy`, or a config key placed on a `machine_model` |
| A preset exists but is not selectable | Index registration, `instantiation`, installation and compatibility | | A preset exists but is not selectable | Index registration, `instantiation`, installation and compatibility |
| A filament is missing, duplicated, or matches the wrong spool | [Compatibility and alias shadowing](references/filament-profiles.md#compatible_printers); [ids](references/ids.md) | | A filament is missing, duplicated, or matches the wrong spool | [Compatibility and alias shadowing](references/filament-profiles.md#compatible_printers); [ids](references/ids.md) |
| Presets differ only by color, or an all-printer library preset lacks `@System` | [Color is a runtime property](references/filament-profiles.md#color-is-a-runtime-property) |
| A bed temperature is ignored | [Plate-specific temperature keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) | | A bed temperature is ignored | [Plate-specific temperature keys](references/filament-profiles.md#bed-temperature-is-twelve-keys-not-one) |
| A change is absent from the running app | Version bump and [installed profile location](references/validation.md#testing-in-the-app) | | A change is absent from the running app | Version bump and [installed profile location](references/validation.md#testing-in-the-app) |
| A check fails | [Error → remedy](references/validation.md#error--remedy) | | A check fails | [Error → remedy](references/validation.md#error--remedy) |
@@ -52,6 +52,15 @@ a brand means adding a folder here; the folder name is a directory label only
collection, so there is no duplicate-name error. collection, so there is no duplicate-name error.
- You may inherit from an instantiated preset as well as from a base; it is common. - You may inherit from an instantiated preset as well as from a base; it is common.
## Color is a runtime property
`filament_id` identifies a product, not a color; filament sync/AMS reads the color from the spool at
runtime. A product ships one all-printer preset and the color is chosen at runtime — never a sibling
preset that differs only by color. A material family (PLA vs PLA Matte vs PLA Silk) is a new product; a
color is not. A printer tune keeps the product alias and does not multiply per color either.
CI does not catch this — per-color presets pass `check` — so it is a review call.
## The two most common contributions ## The two most common contributions
**A printer vendor tuning a generic.** Keep the `Generic X` base name so the alias shadows the library **A printer vendor tuning a generic.** Keep the `Generic X` base name so the alias shadows the library
@@ -46,12 +46,17 @@ to the first `@`; the target half is a label except for reserved forms:
- `@base` — a non-instantiated product root. `@base` is convention; a base is really identified by - `@base` — a non-instantiated product root. `@base` is convention; a base is really identified by
`instantiation: "false"` and no `setting_id` ([the three-part shape](filament-profiles.md#the-three-part-shape)). `instantiation: "false"` and no `setting_id` ([the three-part shape](filament-profiles.md#the-three-part-shape)).
- `@System` — the OrcaFilamentLibrary selectable shim. The literal `Generic <mat> @System` is - `@System` — the OrcaFilamentLibrary selectable shim, and the convention for an all-printer product
load-bearing for 3MF/project recovery, beyond the alias rule ([alias shadowing](filament-profiles.md#alias-shadowing)). (`<Product> @System`, empty `compatible_printers`); not enforced, so a deviation is worth a review
comment. The literal `Generic <mat> @System` is load-bearing for 3MF/project recovery, beyond the
alias rule ([alias shadowing](filament-profiles.md#alias-shadowing)).
- `@<Vendor>`, `@<Vendor> <Model>`, `@<Vendor> <Model> <nozzle> nozzle` — printer tunes, BBL's shape. - `@<Vendor>`, `@<Vendor> <Model>`, `@<Vendor> <Model> <nozzle> nozzle` — printer tunes, BBL's shape.
Other vendors differ (a bare model, a printer serial, Creality's `@<Model>-all`). Specificity is judged Other vendors differ (a bare model, a printer serial, Creality's `@<Model>-all`). Specificity is judged
from `compatible_printers`, not the name from `compatible_printers`, not the name
([one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)). ([one variant, one profile](filament-profiles.md#overlapping-coverage-one-variant-one-profile-per-product)).
- Color is not part of the product name: `<Product> <Color>` presets are not authored; the color is
chosen at runtime
([color is a runtime property](filament-profiles.md#color-is-a-runtime-property)).
## Not the same as the filename ## Not the same as the filename
@@ -15,6 +15,7 @@ The table highlights gaps that need human review. What CI *does* run:
| Whether the intended default survived compatibility selection | The sweep can select a different compatible preset | | Whether the intended default survived compatibility selection | The sweep can select a different compatible preset |
| A dangling `compatible_printers` inside an `instantiation: "false"` base | A base never becomes a `Preset`, so the reference check never sees it (a bad `inherits` in a base *is* caught) | | A dangling `compatible_printers` inside an `instantiation: "false"` base | A base never becomes a `Preset`, so the reference check never sees it (a bad `inherits` in a base *is* caught) |
| A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name | | A `renamed_from` whose old name is still a live preset | The redirect is inert while a live preset carries that name |
| A preset differentiated only by color, or an all-printer library preset without `@System` | Per-color presets split one product across ids and the selector fills with near-duplicates; CI stays green |
| Per-extruder vector length on a multi-nozzle printer | Silently padded (with the **first** value) or truncated | | Per-extruder vector length on a multi-nozzle printer | Silently padded (with the **first** value) or truncated |
## 1. Was the vendor `version` bumped? ## 1. Was the vendor `version` bumped?
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<!-- Bootstrap page for PluginWebDialog. The real plugin HTML is loaded via <!-- Bootstrap page for WebDialog. The real plugin HTML is loaded via
wxWebView::SetPage once this page finishes loading; this file only exists wxWebView::SetPage once this page finishes loading; this file only exists
to bring the webview up. --> to bring the webview up. -->
<html> <html>
+146
View File
@@ -0,0 +1,146 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Dock Panel Demo"
# description = "Opens a dockable panel beside the 3D view that lists the objects on the plate."
# author = "OrcaSlicer"
# version = "0.0.1"
# ///
"""Dock Panel Demo -- orca.host.ui.create_dock_panel().
Run it from the Plugins dialog. It opens an HTML panel docked on the right of the 3D view, in the
same dock area as the sidebar. Drag its caption to dock it on another side (or float it, where the
platform allows), hide it from the page and run the plugin again to bring it back, or close it with
its close button or from the page.
page --orca.postMessage({command: 'refresh'})--> plugin.on_message()
page --orca.postMessage({command: 'hide'})--> plugin.on_message() -> panel.hide()
page --orca.close()--> panel closes, plugin.on_close()
plugin --panel.post({command: 'objects', ...})--> page (orca.onMessage)
"""
import orca
PAGE = """
<style>
body { margin: 0; padding: 12px; font-size: 13px; }
h3 { margin: 0 0 4px; font-size: 14px; }
.note { margin: 0 0 12px; color: var(--orca-muted); font-size: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.actions button.quiet { background: transparent; color: var(--orca-fg); border-color: var(--orca-border); }
table { width: 100%; border-collapse: collapse; }
td.count { text-align: right; font-variant-numeric: tabular-nums; }
#status { margin-top: 10px; color: var(--orca-muted); font-size: 12px; }
</style>
<h3>Objects on the plate</h3>
<p class="note">Docked beside the 3D view. Drag the caption to move it.</p>
<div class="actions">
<button type="button" id="refresh">Refresh</button>
<button type="button" id="hide" class="quiet">Hide</button>
<button type="button" id="close" class="quiet">Close</button>
</div>
<table>
<thead><tr><th>Name</th><th>Parts</th><th>Copies</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<p id="status">Waiting for the plugin...</p>
<script>
(function () {
function text(value) {
var span = document.createElement("span");
span.textContent = value;
return span.innerHTML;
}
function render(message) {
var rows = document.getElementById("rows");
var status = document.getElementById("status");
if (message.error) {
rows.innerHTML = "";
status.textContent = message.error;
return;
}
rows.innerHTML = message.objects.map(function (object) {
return "<tr><td>" + text(object.name) + "</td><td class=\\"count\\">" + object.volumes +
"</td><td class=\\"count\\">" + object.instances + "</td></tr>";
}).join("");
status.textContent = message.objects.length + " object(s), refreshed " + new Date().toLocaleTimeString();
}
orca.onMessage(function (message) {
if (message && message.command === "objects")
render(message);
});
document.getElementById("refresh").addEventListener("click", function () {
orca.postMessage({ command: "refresh" });
});
document.getElementById("hide").addEventListener("click", function () {
orca.postMessage({ command: "hide" });
});
document.getElementById("close").addEventListener("click", function () {
orca.close();
});
orca.postMessage({ command: "refresh" });
})();
</script>
"""
def plate_objects():
try:
model = orca.host.model()
except RuntimeError as error:
return {"command": "objects", "error": str(error)}
return {
"command": "objects",
"objects": [
{"name": obj.name or "(unnamed)", "volumes": obj.volume_count(), "instances": obj.instance_count()}
for obj in model.objects()
],
}
class DockPanelDemo(orca.script.ScriptPluginCapabilityBase):
panel = None
def get_name(self):
return "Dock Panel Demo"
def execute(self):
# The capability instance lives as long as the plugin, so a second run finds the open panel.
if self.panel is not None and self.panel.is_open():
self.panel.show()
return orca.ExecutionResult.success("Dock Panel Demo is already open.")
self.panel = orca.host.ui.create_dock_panel(
html=PAGE,
title="Dock Panel Demo",
width=320,
height=480,
on_message=self.on_message,
on_close=self.on_close,
dock="right",
)
return orca.ExecutionResult.success("Dock Panel Demo opened.")
# Called on the UI thread when the page posts.
def on_message(self, message):
command = (message or {}).get("command")
if command == "refresh":
self.panel.post(plate_objects())
elif command == "hide":
self.panel.hide()
def on_close(self):
self.panel = None
@orca.plugin
class DockPanelDemoPlugin(orca.base):
def register_capabilities(self):
orca.register_capability(DockPanelDemo)
+46 -64
View File
@@ -5,6 +5,7 @@
//BBS //BBS
#include "Preset.hpp" #include "Preset.hpp"
#include "Exception.hpp" #include "Exception.hpp"
#include "InstanceLock.hpp"
#include "LocalesUtils.hpp" #include "LocalesUtils.hpp"
#include "Thread.hpp" #include "Thread.hpp"
#include "format.hpp" #include "format.hpp"
@@ -730,10 +731,13 @@ static bool verify_config_file_checksum(boost::nowide::ifstream &ifs)
#ifdef USE_JSON_CONFIG #ifdef USE_JSON_CONFIG
std::string AppConfig::load() std::string AppConfig::load(bool read_only)
{ {
json j; json j;
// Keep another instance from replacing or restoring the file mid-read.
InstanceLock instance_lock(read_only ? std::string() : lock_path());
// 1) Read the complete config file into a boost::property_tree. // 1) Read the complete config file into a boost::property_tree.
namespace pt = boost::property_tree; namespace pt = boost::property_tree;
pt::ptree tree; pt::ptree tree;
@@ -983,7 +987,6 @@ void AppConfig::save()
// The config is first written to a file with a PID suffix and then moved // The config is first written to a file with a PID suffix and then moved
// to avoid race conditions with multiple instances of Slic3r // to avoid race conditions with multiple instances of Slic3r
const auto path = config_path(); const auto path = config_path();
std::string path_pid = (boost::format("%1%.%2%") % path % get_current_pid()).str();
json j; json j;
@@ -1113,43 +1116,18 @@ void AppConfig::save()
j["local_machines"][local_machine.first] = m_json; j["local_machines"][local_machine.first] = m_json;
} }
boost::nowide::ofstream c; const std::string config_str = j.dump(1, '\t');
c.open(path_pid, std::ios::out | std::ios::trunc); if (write_config_file(path, config_str + "\n", config_str))
c << j.dump(1, '\t') << std::endl;
#ifdef WIN32
// WIN32 specific: The final "rename_file()" call is not safe in case of an application crash, there is no atomic "rename file" API
// provided by Windows (sic!). Therefore we save a MD5 checksum to be able to verify file corruption. In addition,
// we save the config file into a backup first before moving it to the final destination.
c << appconfig_md5_hash_line(j.dump(1, '\t'));
#endif
c.close();
if (c.fail()) {
BOOST_LOG_TRIVIAL(error) << "Failed to write new configuration to " << path_pid << "; aborting attempt to overwrite original configuration";
return;
}
#ifdef WIN32
// Make a backup of the configuration file before copying it to the final destination.
std::string error_message;
std::string backup_path = (boost::format("%1%.bak") % path).str();
// Copy configuration file with PID suffix into the configuration file with "bak" suffix.
if (copy_file(path_pid, backup_path, error_message, false) != SUCCESS)
BOOST_LOG_TRIVIAL(error) << "Copying from " << path_pid << " to " << backup_path << " failed. Failed to create a backup configuration.";
#endif
// Rename the config atomically.
// On Windows, the rename is likely NOT atomic, thus it may fail if PrusaSlicer crashes on another thread in the meanwhile.
// To cope with that, we already made a backup of the config on Windows.
rename_file(path_pid, path);
m_dirty = false; m_dirty = false;
} }
#else #else
std::string AppConfig::load() std::string AppConfig::load(bool read_only)
{ {
// Keep another instance from replacing or restoring the file mid-read.
InstanceLock instance_lock(read_only ? std::string() : lock_path());
// 1) Read the complete config file into a boost::property_tree. // 1) Read the complete config file into a boost::property_tree.
namespace pt = boost::property_tree; namespace pt = boost::property_tree;
pt::ptree tree; pt::ptree tree;
@@ -1287,7 +1265,6 @@ void AppConfig::save()
// The config is first written to a file with a PID suffix and then moved // The config is first written to a file with a PID suffix and then moved
// to avoid race conditions with multiple instances of Slic3r // to avoid race conditions with multiple instances of Slic3r
const auto path = config_path(); const auto path = config_path();
std::string path_pid = (boost::format("%1%.%2%") % path % get_current_pid()).str();
std::stringstream config_ss; std::stringstream config_ss;
if (m_mode == EAppMode::Editor) if (m_mode == EAppMode::Editor)
@@ -1323,39 +1300,39 @@ void AppConfig::save()
// One empty line before the MD5 sum. // One empty line before the MD5 sum.
config_ss << std::endl; config_ss << std::endl;
std::string config_str = config_ss.str(); const std::string config_str = config_ss.str();
boost::nowide::ofstream c; if (write_config_file(path, config_str, config_str))
c.open(path_pid, std::ios::out | std::ios::trunc);
c << config_str;
#ifdef WIN32
// WIN32 specific: The final "rename_file()" call is not safe in case of an application crash, there is no atomic "rename file" API
// provided by Windows (sic!). Therefore we save a MD5 checksum to be able to verify file corruption. In addition,
// we save the config file into a backup first before moving it to the final destination.
c << appconfig_md5_hash_line(config_str);
#endif
c.close();
if (c.fail()) {
BOOST_LOG_TRIVIAL(error) << "Failed to write new configuration to " << path_pid << "; aborting attempt to overwrite original configuration";
return;
}
#ifdef WIN32
// Make a backup of the configuration file before copying it to the final destination.
std::string error_message;
std::string backup_path = (boost::format("%1%.bak") % path).str();
// Copy configuration file with PID suffix into the configuration file with "bak" suffix.
if (copy_file(path_pid, backup_path, error_message, false) != SUCCESS)
BOOST_LOG_TRIVIAL(error) << "Copying from " << path_pid << " to " << backup_path << " failed. Failed to create a backup configuration.";
#endif
// Rename the config atomically.
// On Windows, the rename is likely NOT atomic, thus it may fail if PrusaSlicer crashes on another thread in the meanwhile.
// To cope with that, we already made a backup of the config on Windows.
rename_file(path_pid, path);
m_dirty = false; m_dirty = false;
} }
#endif #endif
bool AppConfig::write_config_file(const std::string &path, std::string body, const std::string &checksum_source)
{
// Everything before this is assembly; only the writes need the other instances kept out.
InstanceLock instance_lock(lock_path());
#ifdef WIN32
// WIN32 specific: the final replace is not safe in case of an application crash, there is no atomic "rename file" API
// provided by Windows (sic!). Therefore we save a MD5 checksum to be able to verify file corruption. In addition,
// we save the config file into a backup first before moving it to the final destination.
body += appconfig_md5_hash_line(checksum_source);
#endif
// Not flushed to the device: the idle handler saves on the GUI thread after
// any change, and the rename already gives a complete old or new file.
if (const std::error_code ec = write_file_atomically(path, body)) {
BOOST_LOG_TRIVIAL(error) << "Failed to write the configuration " << path << ": " << ec.message() << "; trying again in 10 s";
m_retry_save_at = std::chrono::steady_clock::now() + std::chrono::seconds(10);
return false;
}
m_retry_save_at = {};
#ifdef WIN32
// Written after the config, so the backup never holds a state that was not confirmed written.
const std::string backup_path = (boost::format("%1%.bak") % path).str();
if (const std::error_code ec = write_file_atomically(backup_path, body))
BOOST_LOG_TRIVIAL(error) << "Failed to write the backup configuration " << backup_path << ": " << ec.message();
#endif
return true;
}
bool AppConfig::get_variant(const std::string &vendor, const std::string &model, const std::string &variant) const bool AppConfig::get_variant(const std::string &vendor, const std::string &model, const std::string &variant) const
{ {
const auto it_v = m_vendors.find(vendor); const auto it_v = m_vendors.find(vendor);
@@ -1844,6 +1821,11 @@ void AppConfig::reset_selections()
} }
} }
std::string AppConfig::lock_path()
{
return Slic3r::data_dir().empty() ? std::string() : config_path() + ".lock";
}
std::string AppConfig::config_path() std::string AppConfig::config_path()
{ {
#ifdef USE_JSON_CONFIG #ifdef USE_JSON_CONFIG
@@ -1880,7 +1862,7 @@ bool AppConfig::exists()
std::string AppConfig::load_if_exists() std::string AppConfig::load_if_exists()
{ {
return boost::filesystem::exists(loading_path()) ? load() : std::string(); return boost::filesystem::exists(loading_path()) ? load(/*read_only=*/true) : std::string();
} }
}; // namespace Slic3r }; // namespace Slic3r
+16 -1
View File
@@ -2,6 +2,7 @@
#define slic3r_AppConfig_hpp_ #define slic3r_AppConfig_hpp_
#include <set> #include <set>
#include <chrono>
#include <map> #include <map>
#include <string> #include <string>
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
@@ -121,14 +122,18 @@ public:
// Load the slic3r.ini from a user profile directory (or a datadir, if configured). // Load the slic3r.ini from a user profile directory (or a datadir, if configured).
// Return an error string, or an empty string on success. // Return an error string, or an empty string on success.
std::string load(); std::string load(bool read_only = false);
// Treat a missing config as default state; otherwise load it normally. // Treat a missing config as default state; otherwise load it normally.
// The CLI's load: it never saves, so it takes no lock and creates no lock file.
std::string load_if_exists(); std::string load_if_exists();
// Store the slic3r.ini into a user profile directory (or a datadir, if configured). // Store the slic3r.ini into a user profile directory (or a datadir, if configured).
void save(); void save();
// Does this config need to be saved? // Does this config need to be saved?
bool dirty() const { return m_dirty; } bool dirty() const { return m_dirty; }
// False for ten seconds after a failed write, so the idle handler does not
// repeat a hopeless attempt on every event; an explicit save() always tries.
bool save_due() const { return std::chrono::steady_clock::now() >= m_retry_save_at; }
void set_dirty() { m_dirty = true; } void set_dirty() { m_dirty = true; }
@@ -338,6 +343,8 @@ public:
// Get the default config path from Slic3r::data_dir(). // Get the default config path from Slic3r::data_dir().
std::string config_path(); std::string config_path();
// Lock file guarding config_path() against other running instances; empty without a data dir.
std::string lock_path();
// Returns true if the user's data directory comes from before Slic3r 1.40.0 (no updating) // Returns true if the user's data directory comes from before Slic3r 1.40.0 (no updating)
bool legacy_datadir() const { return m_legacy_datadir; } bool legacy_datadir() const { return m_legacy_datadir; }
@@ -448,8 +455,16 @@ private:
// Preset for each machine // Preset for each machine
MachineSettingMap m_printer_settings; MachineSettingMap m_printer_settings;
// Writes the assembled config text, and on Windows its checksum and a backup copy; false when the
// config itself could not be written, in which case the caller stays dirty and retries. `checksum_source`
// is the text load() will verify, which for the JSON config ends before the trailing newline.
bool write_config_file(const std::string &path, std::string body, const std::string &checksum_source);
// Has any value been modified since the config.ini has been last saved or loaded? // Has any value been modified since the config.ini has been last saved or loaded?
bool m_dirty; bool m_dirty;
// After a failed write, save_due() is false for the next ten seconds, so the
// idle handler does not repeat a hopeless write on every event.
std::chrono::steady_clock::time_point m_retry_save_at{};
// Original version found in the ini file before it was overwritten // Original version found in the ini file before it was overwritten
Semver m_orig_version; Semver m_orig_version;
// Whether the existing version is before system profiles & configuration updating // Whether the existing version is before system profiles & configuration updating
+2
View File
@@ -304,6 +304,8 @@ set(lisbslic3r_sources
Geometry/VoronoiUtils.cpp Geometry/VoronoiUtils.cpp
Geometry/VoronoiUtils.hpp Geometry/VoronoiUtils.hpp
Geometry/VoronoiVisualUtils.hpp Geometry/VoronoiVisualUtils.hpp
InstanceLock.cpp
InstanceLock.hpp
Int128.hpp Int128.hpp
KDTreeIndirect.hpp KDTreeIndirect.hpp
Layer.cpp Layer.cpp
+3 -5
View File
@@ -1522,11 +1522,9 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name,
// Serialize first: if that throws (invalid UTF-8), the existing file stays untouched. // Serialize first: if that throws (invalid UTF-8), the existing file stays untouched.
std::ostringstream ss; std::ostringstream ss;
this->save_to_json(ss, name, from, version); this->save_to_json(ss, name, from, version);
boost::nowide::ofstream c; if (const std::error_code ec = write_file_atomically(file, ss.str()))
c.open(file, std::ios::out | std::ios::trunc); BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": failed to save config to %1%: %2%") % file % ec.message();
c << ss.str(); else
c.close();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", saved config to %1%\n")%file; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", saved config to %1%\n")%file;
} }
+165
View File
@@ -0,0 +1,165 @@
#include "InstanceLock.hpp"
#include <map>
#include <memory>
#include <system_error>
#include <thread>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#ifdef _WIN32
#include <boost/interprocess/sync/file_lock.hpp>
#include <boost/nowide/convert.hpp>
#else
#include <cerrno>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#endif
namespace Slic3r {
#ifdef _WIN32
// LockFileEx, held by this handle alone.
using NativeFileLock = boost::interprocess::file_lock;
#else
// flock(2) rather than an fcntl lock: it belongs to this open file description,
// so any other code in the process that opens and closes the lock file, as a
// backup or an export walking the data dir might, cannot drop it. An fcntl
// lock would go with the first such close.
class NativeFileLock
{
public:
explicit NativeFileLock(const char *path) : m_fd(::open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0644))
{
if (m_fd < 0)
throw std::system_error(errno, std::generic_category(), path);
}
~NativeFileLock() { ::close(m_fd); }
bool try_lock()
{
if (::flock(m_fd, LOCK_EX | LOCK_NB) == 0)
return true;
// A signal (a child exiting, for one) interrupts the call like any other; the caller polls again.
if (errno == EWOULDBLOCK || errno == EINTR)
return false;
throw std::system_error(errno, std::generic_category(), "flock");
}
void unlock() { ::flock(m_fd, LOCK_UN); }
private:
int m_fd;
};
#endif
// One slot per lock file, shared by every guard in the process: one lock
// object per path behind a mutex is what makes the guard re-entrant and safe
// to use from the preset sync thread and the GUI thread at once. The lock
// file is opened by the outermost guard and closed when it goes, so the file
// is never held open between guards: whatever is at the path is what gets
// locked, and a data dir can be removed once nothing is saving into it. The
// file is kept rather than deleted on release because the lock state lives in
// the kernel on the open file, and deleting it would let a third instance
// lock a fresh file while the second still holds the old one.
struct InstanceLock::Slot
{
std::recursive_mutex mutex;
// Non-null exactly while this process holds the file lock.
std::unique_ptr<NativeFileLock> file_lock;
int depth{0};
// Until this point, after a guard could not open, lock or wait out the
// file, guards do not touch it.
std::chrono::steady_clock::time_point cooldown_until{};
};
InstanceLock::Slot &InstanceLock::slot_for(const std::string &lock_file_path)
{
// Never freed: a save during static destruction still needs its slot.
static auto *registry_mutex = new std::mutex();
static auto *registry = new std::map<std::string, std::unique_ptr<Slot>>();
std::lock_guard<std::mutex> guard(*registry_mutex);
std::unique_ptr<Slot> &slot = (*registry)[lock_file_path];
if (! slot)
slot = std::make_unique<Slot>();
return *slot;
}
// Starts the cool-down. Called with the slot mutex held.
void InstanceLock::defer(Slot &slot, const std::string &reason)
{
slot.cooldown_until = std::chrono::steady_clock::now() + cooldown;
BOOST_LOG_TRIVIAL(warning) << reason << "; proceeding without the lock for the next " << cooldown.count() << " ms";
}
// Creates the lock file if needed and opens it, or starts the cool-down.
// Called with the slot mutex held.
bool InstanceLock::open_lock_file(Slot &slot, const std::string &lock_file_path)
{
try {
#ifdef _WIN32
// The lock opens an existing file; created once, on the first miss.
const std::wstring wide_path = boost::nowide::widen(lock_file_path);
try {
slot.file_lock = std::make_unique<NativeFileLock>(wide_path.c_str());
} catch (const std::exception &) {
boost::nowide::ofstream(lock_file_path, std::ios::app).close();
slot.file_lock = std::make_unique<NativeFileLock>(wide_path.c_str());
}
#else
slot.file_lock = std::make_unique<NativeFileLock>(lock_file_path.c_str());
#endif
return true;
} catch (const std::exception &e) {
defer(slot, "Cannot open lock file " + lock_file_path + ": " + e.what() + " (check its owner and permissions)");
return false;
}
}
InstanceLock::InstanceLock(const std::string &lock_file_path, std::chrono::milliseconds timeout)
{
if (lock_file_path.empty())
return;
m_slot = &slot_for(lock_file_path);
m_slot_guard = std::unique_lock<std::recursive_mutex>(m_slot->mutex);
const auto now = std::chrono::steady_clock::now();
if (m_slot->depth == 0 && now >= m_slot->cooldown_until && open_lock_file(*m_slot, lock_file_path)) {
const auto deadline = now + timeout;
bool taken = false;
for (;;) {
try {
if ((taken = m_slot->file_lock->try_lock()))
break;
} catch (const std::exception &e) {
defer(*m_slot, "Cannot lock " + lock_file_path + ": " + e.what());
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
defer(*m_slot, "Another instance has held " + lock_file_path + " for over " + std::to_string(timeout.count()) + " ms");
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
if (! taken)
m_slot->file_lock.reset();
}
// Counted last, so a throw above leaves the slot exactly as it was found.
++ m_slot->depth;
m_locked = m_slot->file_lock != nullptr;
}
InstanceLock::~InstanceLock()
{
if (m_slot == nullptr)
return;
if (-- m_slot->depth == 0 && m_slot->file_lock) {
try {
m_slot->file_lock->unlock();
} catch (const std::exception &e) {
BOOST_LOG_TRIVIAL(warning) << "Cannot unlock instance lock: " << e.what();
}
m_slot->file_lock.reset();
}
}
} // namespace Slic3r
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <chrono>
#include <mutex>
#include <string>
namespace Slic3r {
// Scoped write lock on a file shared by every running instance of the
// application, such as the app config or the user preset directory: threads
// of this process are serialised through a recursive mutex, other processes
// through an advisory OS file lock on `lock_file_path`. The lock file is
// created on first use and kept; the OS releases the lock when its holder
// exits, so a crashed instance never leaves a stale lock behind.
//
// Best effort: when the lock file cannot be opened or locked, or another
// instance still holds it after `timeout`, the guard keeps only the in-process
// mutex, locked() reports false and the write proceeds, since a hung instance
// must never block another one from saving. For `cooldown` afterwards guards
// leave the file alone. The wait for the in-process mutex is bounded only by
// the longest critical section, so a guard covers a few file operations and
// nothing slower.
//
// Lock order: the preset collection mutex may be held when a guard is taken
// (set_sync_info_and_save() calls save_info() under it), never the reverse;
// that is why the guards sit at the leaf readers and writers and why a guard
// must not be added around save_user_presets(), which takes the collection
// mutex through delete_preset().
class InstanceLock
{
public:
// Long against a critical section of milliseconds, short against the GUI
// thread, which is where most guards are taken.
static constexpr std::chrono::milliseconds default_timeout{1000};
// Long enough that a holder stuck in a debugger does not cost a stall per
// save; mutable so tests can shorten it.
static inline std::chrono::milliseconds cooldown{10000};
// An empty path makes the guard a no-op.
explicit InstanceLock(const std::string &lock_file_path, std::chrono::milliseconds timeout = default_timeout);
~InstanceLock();
InstanceLock(const InstanceLock &) = delete;
InstanceLock &operator=(const InstanceLock &) = delete;
// True while this process holds the cross-process file lock.
bool locked() const { return m_locked; }
private:
struct Slot;
static Slot &slot_for(const std::string &lock_file_path);
static bool open_lock_file(Slot &slot, const std::string &lock_file_path);
static void defer(Slot &slot, const std::string &reason);
Slot *m_slot{nullptr};
std::unique_lock<std::recursive_mutex> m_slot_guard;
bool m_locked{false};
};
} // namespace Slic3r
+82 -34
View File
@@ -48,6 +48,9 @@
#include "libslic3r.h" #include "libslic3r.h"
#include "Utils.hpp" #include "Utils.hpp"
#include "InstanceLock.hpp"
#include <sstream>
#include "Time.hpp" #include "Time.hpp"
#include "PlaceholderParser.hpp" #include "PlaceholderParser.hpp"
#include "libslic3r/GCode/Thumbnails.hpp" #include "libslic3r/GCode/Thumbnails.hpp"
@@ -104,6 +107,31 @@ std::string get_preset_canonical_name(const std::string &preset_bare_name, const
} }
} }
std::string user_presets_lock_path(bool read_only)
{
return read_only || data_dir().empty() ? std::string() : (fs::path(data_dir()) / (PRESET_USER_DIR ".lock")).string();
}
// Removes a preset file the scan could not load, and its .info, under the lock.
// Without the lock, in a cool-down, the file may be another instance's fresh
// write that this scan merely raced, so it stays for the next scan to judge.
static void remove_preset_files(const std::string &preset_file, bool read_only)
{
if (read_only)
return;
const std::string lock_path = user_presets_lock_path();
InstanceLock instance_lock(lock_path);
if (! lock_path.empty() && ! instance_lock.locked()) {
BOOST_LOG_TRIVIAL(warning) << "Leaving unreadable preset " << preset_file << " in place: the instance lock is not held";
return;
}
boost::system::error_code ec;
fs::path file_path(preset_file);
fs::remove(file_path, ec);
file_path.replace_extension(".info");
fs::remove(file_path, ec);
}
std::string get_preset_bare_name(const std::string &canonical_name) std::string get_preset_bare_name(const std::string &canonical_name)
{ {
const auto pos = canonical_name.find_last_of('/'); const auto pos = canonical_name.find_last_of('/');
@@ -646,18 +674,20 @@ void Preset::save_info(std::string file)
file = idx_file.string(); file = idx_file.string();
} }
boost::nowide::ofstream c;
c.open(file, std::ios::out | std::ios::trunc);
std::string sync_info_to_save; std::string sync_info_to_save;
//BBS: hold is used for stop requesting to server this time //BBS: hold is used for stop requesting to server this time
if (this->sync_info.compare("hold") != 0) if (this->sync_info.compare("hold") != 0)
sync_info_to_save = this->sync_info; sync_info_to_save = this->sync_info;
std::ostringstream c;
c << "sync_info" << " = " << sync_info_to_save << std::endl; c << "sync_info" << " = " << sync_info_to_save << std::endl;
c << "user_id" << " = " << this->user_id << std::endl; c << "user_id" << " = " << this->user_id << std::endl;
c << "setting_id" << " = " << this->setting_id << std::endl; c << "setting_id" << " = " << this->setting_id << std::endl;
c << "base_id" << " = " << this->base_id << std::endl; c << "base_id" << " = " << this->base_id << std::endl;
c << "updated_time" << " = " << std::to_string(this->updated_time) << std::endl; c << "updated_time" << " = " << std::to_string(this->updated_time) << std::endl;
c.close();
InstanceLock instance_lock(user_presets_lock_path());
if (const std::error_code ec = write_file_atomically(file, c.str()))
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": failed to save " << file << ": " << ec.message();
} }
void Preset::remove_files(bool cloud_already_deleted) void Preset::remove_files(bool cloud_already_deleted)
@@ -666,6 +696,7 @@ void Preset::remove_files(bool cloud_already_deleted)
if (this->is_project_embedded) { if (this->is_project_embedded) {
return; return;
} }
InstanceLock instance_lock(user_presets_lock_path());
// Erase the preset file. // Erase the preset file.
boost::nowide::remove(this->file.c_str()); boost::nowide::remove(this->file.c_str());
fs::path idx_path(this->file); fs::path idx_path(this->file);
@@ -702,12 +733,16 @@ void Preset::save(DynamicPrintConfig* parent_config)
else else
from_str = std::string("Default"); from_str = std::string("Default");
boost::filesystem::create_directories(fs::path(this->file).parent_path());
const std::string bare_name = get_preset_bare_name(this->name); const std::string bare_name = get_preset_bare_name(this->name);
// What gets written: the diff against the parent, the config plus its
// filament id, or the config as is. Built before the lock is taken so the
// exclusive window covers only the file writes.
DynamicPrintConfig temp_config;
const DynamicPrintConfig *to_save = &this->config;
//BBS: only save difference if it has parent //BBS: only save difference if it has parent
if (parent_config) { if (parent_config) {
DynamicPrintConfig temp_config;
std::vector<std::string> dirty_options = config.diff(*parent_config); std::vector<std::string> dirty_options = config.diff(*parent_config);
std::string extruder_id_name, extruder_variant_name; std::string extruder_id_name, extruder_variant_name;
@@ -743,13 +778,22 @@ void Preset::save(DynamicPrintConfig* parent_config)
opt_dst->set(opt_src); opt_dst->set(opt_src);
} }
} }
temp_config.save_to_json(this->file, bare_name, from_str, this->version.to_string()); to_save = &temp_config;
} else if (!filament_id.empty() && inherits().empty()) { } else if (!filament_id.empty() && inherits().empty()) {
DynamicPrintConfig temp_config = config; temp_config = config;
temp_config.set_key_value(BBL_JSON_KEY_FILAMENT_ID, new ConfigOptionString(filament_id)); temp_config.set_key_value(BBL_JSON_KEY_FILAMENT_ID, new ConfigOptionString(filament_id));
temp_config.save_to_json(this->file, bare_name, from_str, this->version.to_string()); to_save = &temp_config;
} else { }
this->config.save_to_json(this->file, bare_name, from_str, this->version.to_string());
std::ostringstream json;
to_save->save_to_json(json, bare_name, from_str, this->version.to_string());
InstanceLock instance_lock(user_presets_lock_path());
boost::filesystem::create_directories(fs::path(this->file).parent_path());
if (const std::error_code ec = write_file_atomically(this->file, json.str())) {
// No .info either: one without its preset reads as a cloud deletion request.
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": failed to save " << this->file << ": " << ec.message();
return;
} }
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " save config for: " << this->name << " and filament_id: " << filament_id << " and base_id: " << this->base_id; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " save config for: " << this->name << " and filament_id: " << filament_id << " and base_id: " << this->base_id;
@@ -770,6 +814,7 @@ void Preset::reload(Preset const &parent)
std::string reason; std::string reason;
ForwardCompatibilitySubstitutionRule substitution_rule = ForwardCompatibilitySubstitutionRule::Disable; ForwardCompatibilitySubstitutionRule substitution_rule = ForwardCompatibilitySubstitutionRule::Disable;
try { try {
InstanceLock instance_lock(user_presets_lock_path());
ConfigSubstitutions config_substitutions = config.load_from_json(file, substitution_rule, key_values, reason); ConfigSubstitutions config_substitutions = config.load_from_json(file, substitution_rule, key_values, reason);
this->config = parent.config; this->config = parent.config;
this->config.apply(std::move(config)); this->config.apply(std::move(config));
@@ -1707,6 +1752,9 @@ void PresetCollection::load_presets(
//BBS: change to json format //BBS: change to json format
for (auto &dir_entry : boost::filesystem::directory_iterator(dir)) for (auto &dir_entry : boost::filesystem::directory_iterator(dir))
{ {
// Per file, so a save on another thread or in another instance never
// waits for the whole scan.
InstanceLock instance_lock(user_presets_lock_path(read_only));
std::string file_name = dir_entry.path().filename().string(); std::string file_name = dir_entry.path().filename().string();
//if (Slic3r::is_ini_file(dir_entry)) { //if (Slic3r::is_ini_file(dir_entry)) {
if (Slic3r::is_json_file(file_name)) { if (Slic3r::is_json_file(file_name)) {
@@ -1725,30 +1773,26 @@ void PresetCollection::load_presets(
preset.file = dir_entry.path().string(); preset.file = dir_entry.path().string();
// Load the preset file, apply preset values on top of defaults. // Load the preset file, apply preset values on top of defaults.
try { try {
DynamicPrintConfig config;
std::map<std::string, std::string> key_values;
std::string reason;
ConfigSubstitutions config_substitutions;
fs::path idx_path(preset.file); fs::path idx_path(preset.file);
idx_path.replace_extension(".info"); idx_path.replace_extension(".info");
if (fs::exists(idx_path)) { if (fs::exists(idx_path)) {
preset.load_info(idx_path.string()); preset.load_info(idx_path.string());
} }
DynamicPrintConfig config;
//BBS: change to json format //BBS: change to json format
//ConfigSubstitutions config_substitutions = config.load_from_ini(preset.file, substitution_rule); //ConfigSubstitutions config_substitutions = config.load_from_ini(preset.file, substitution_rule);
std::map<std::string, std::string> key_values; config_substitutions = config.load_from_json(preset.file, substitution_rule, key_values, reason);
std::string reason;
ConfigSubstitutions config_substitutions = config.load_from_json(preset.file, substitution_rule, key_values, reason);
if (! config_substitutions.empty())
substitutions.push_back({ preset.name, m_type, PresetConfigSubstitutions::Source::UserFile, preset.file, std::move(config_substitutions) });
if (!reason.empty()) { if (!reason.empty()) {
fs::path file_path(preset.file); remove_preset_files(preset.file, read_only);
if (!read_only && fs::exists(file_path))
fs::remove(file_path);
file_path.replace_extension(".info");
if (!read_only && fs::exists(file_path))
fs::remove(file_path);
BOOST_LOG_TRIVIAL(error) << boost::format("parse config %1% failed")%preset.file; BOOST_LOG_TRIVIAL(error) << boost::format("parse config %1% failed")%preset.file;
++m_errors; ++m_errors;
continue; continue;
} }
if (! config_substitutions.empty())
substitutions.push_back({ preset.name, m_type, PresetConfigSubstitutions::Source::UserFile, preset.file, std::move(config_substitutions) });
std::string version_str = key_values[BBL_JSON_KEY_VERSION]; std::string version_str = key_values[BBL_JSON_KEY_VERSION];
boost::optional<Semver> version = Semver::parse(version_str); boost::optional<Semver> version = Semver::parse(version_str);
@@ -1832,23 +1876,13 @@ void PresetCollection::load_presets(
} catch (const std::ifstream::failure &err) { } catch (const std::ifstream::failure &err) {
++m_errors; ++m_errors;
BOOST_LOG_TRIVIAL(error) << boost::format("The user-config cannot be loaded: %1%. Reason: %2%")%preset.file %err.what(); BOOST_LOG_TRIVIAL(error) << boost::format("The user-config cannot be loaded: %1%. Reason: %2%")%preset.file %err.what();
fs::path file_path(preset.file); remove_preset_files(preset.file, read_only);
if (!read_only && fs::exists(file_path))
fs::remove(file_path);
file_path.replace_extension(".info");
if (!read_only && fs::exists(file_path))
fs::remove(file_path);
//throw Slic3r::RuntimeError(std::string("The selected preset cannot be loaded: ") + preset.file + "\n\tReason: " + err.what()); //throw Slic3r::RuntimeError(std::string("The selected preset cannot be loaded: ") + preset.file + "\n\tReason: " + err.what());
} catch (const std::runtime_error &err) { } catch (const std::runtime_error &err) {
++m_errors; ++m_errors;
BOOST_LOG_TRIVIAL(error) << boost::format("Failed loading the user-config file: %1%. Reason: %2%")%preset.file %err.what(); BOOST_LOG_TRIVIAL(error) << boost::format("Failed loading the user-config file: %1%. Reason: %2%")%preset.file %err.what();
//throw Slic3r::RuntimeError(std::string("Failed loading the preset file: ") + preset.file + "\n\tReason: " + err.what()); //throw Slic3r::RuntimeError(std::string("Failed loading the preset file: ") + preset.file + "\n\tReason: " + err.what());
fs::path file_path(preset.file); remove_preset_files(preset.file, read_only);
if (!read_only && fs::exists(file_path))
fs::remove(file_path);
file_path.replace_extension(".info");
if (!read_only && fs::exists(file_path))
fs::remove(file_path);
} }
if (preset_loaded_fn != nullptr) if (preset_loaded_fn != nullptr)
@@ -4201,8 +4235,15 @@ void PhysicalPrinter::update_preset_names_in_config()
} }
} }
void PhysicalPrinter::save(DynamicPrintConfig* /* parent_config */)
{
InstanceLock instance_lock(user_presets_lock_path());
this->config.save_to_json(this->file, std::string("Physical_Printer"), std::string("User"), std::string(SLIC3R_VERSION));
}
void PhysicalPrinter::save(const std::string& file_name_from, const std::string& file_name_to) void PhysicalPrinter::save(const std::string& file_name_from, const std::string& file_name_to)
{ {
InstanceLock instance_lock(user_presets_lock_path());
// rename the file // rename the file
boost::nowide::rename(file_name_from.data(), file_name_to.data()); boost::nowide::rename(file_name_from.data(), file_name_to.data());
this->file = file_name_to; this->file = file_name_to;
@@ -4334,6 +4375,7 @@ void PhysicalPrinterCollection::load_printers(
continue; continue;
} }
try { try {
InstanceLock instance_lock(user_presets_lock_path());
PhysicalPrinter printer(name, this->default_config()); PhysicalPrinter printer(name, this->default_config());
printer.file = dir_entry.path().string(); printer.file = dir_entry.path().string();
// Load the preset file, apply preset values on top of defaults. // Load the preset file, apply preset values on top of defaults.
@@ -4526,7 +4568,10 @@ bool PhysicalPrinterCollection::delete_printer(const std::string& name)
const PhysicalPrinter& printer = *it; const PhysicalPrinter& printer = *it;
// Erase the preset file. // Erase the preset file.
{
InstanceLock instance_lock(user_presets_lock_path());
boost::nowide::remove(printer.file.c_str()); boost::nowide::remove(printer.file.c_str());
}
m_printers.erase(it); m_printers.erase(it);
return true; return true;
} }
@@ -4538,7 +4583,10 @@ bool PhysicalPrinterCollection::delete_selected_printer()
const PhysicalPrinter& printer = this->get_selected_printer(); const PhysicalPrinter& printer = this->get_selected_printer();
// Erase the preset file. // Erase the preset file.
{
InstanceLock instance_lock(user_presets_lock_path());
boost::nowide::remove(printer.file.c_str()); boost::nowide::remove(printer.file.c_str());
}
// Remove the preset from the list. // Remove the preset from the list.
m_printers.erase(m_printers.begin() + m_idx_selected); m_printers.erase(m_printers.begin() + m_idx_selected);
// unselect all printers // unselect all printers
+7 -1
View File
@@ -484,6 +484,12 @@ std::string get_preset_canonical_name(const std::string &preset_bare_name, const
// Tail segment of a canonical name — what's written to the bundle's .json filename and JSON "name" field. // Tail segment of a canonical name — what's written to the bundle's .json filename and JSON "name" field.
std::string get_preset_bare_name(const std::string &canonical_name); std::string get_preset_bare_name(const std::string &canonical_name);
// Lock file guarding every user preset file under data_dir() against other
// running instances and the preset sync thread. Empty without a data dir, and
// for a read-only load (the CLI), which never rewrites or deletes and may run
// many jobs on one data dir.
std::string user_presets_lock_path(bool read_only = false);
// Resolve an origin from a directory path when the caller passes Kind::Auto. // Resolve an origin from a directory path when the caller passes Kind::Auto.
PresetOrigin detect_origin_from_path(const boost::filesystem::path &path, const PresetOrigin &explicit_origin = PresetOrigin()); PresetOrigin detect_origin_from_path(const boost::filesystem::path &path, const PresetOrigin &explicit_origin = PresetOrigin());
@@ -1053,7 +1059,7 @@ public:
//BBS: change to json format //BBS: change to json format
//void save() { this->config.save(this->file); } //void save() { this->config.save(this->file); }
void save(DynamicPrintConfig* parent_config) { this->config.save_to_json(this->file, std::string("Physical_Printer"), std::string("User"), std::string(SLIC3R_VERSION)); } void save(DynamicPrintConfig* parent_config);
void save(const std::string& file_name_from, const std::string& file_name_to); void save(const std::string& file_name_from, const std::string& file_name_to);
void update_from_preset(const Preset& preset); void update_from_preset(const Preset& preset);
+29 -12
View File
@@ -1,3 +1,4 @@
#include <atomic>
#include <cassert> #include <cassert>
#include <chrono> #include <chrono>
#include <ctime> #include <ctime>
@@ -12,6 +13,7 @@
#include "libslic3r.h" #include "libslic3r.h"
#include "I18N.hpp" #include "I18N.hpp"
#include "Utils.hpp" #include "Utils.hpp"
#include "InstanceLock.hpp"
#include "LocalesUtils.hpp" #include "LocalesUtils.hpp"
#include "Model.hpp" #include "Model.hpp"
#include "TriangleSelector.hpp" #include "TriangleSelector.hpp"
@@ -1227,6 +1229,13 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For
const auto user_load_t0 = std::chrono::steady_clock::now(); const auto user_load_t0 = std::chrono::steady_clock::now();
// Reads one bundle's metadata under the lock, per file, so the lock is never
// held when bundles.WriteLock() is taken afterwards.
auto load_bundle_metadata = [read_only](const fs::path &metadata_file, BundleMetadata &metadata) {
InstanceLock instance_lock(user_presets_lock_path(read_only));
return metadata.load_from_json(metadata_file.string());
};
// Load bundle metadata from _local directory first // Load bundle metadata from _local directory first
fs::path local_dir(folder / PRESET_LOCAL_DIR); fs::path local_dir(folder / PRESET_LOCAL_DIR);
if (fs::exists(local_dir)) { if (fs::exists(local_dir)) {
@@ -1240,7 +1249,7 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For
if (!fs::exists(metadata_file)) continue; if (!fs::exists(metadata_file)) continue;
BundleMetadata metadata; BundleMetadata metadata;
if (!metadata.load_from_json(metadata_file.string())) continue; if (!load_bundle_metadata(metadata_file, metadata)) continue;
metadata.print_presets.clear(); metadata.print_presets.clear();
metadata.filament_presets.clear(); metadata.filament_presets.clear();
metadata.printer_presets.clear(); metadata.printer_presets.clear();
@@ -1275,7 +1284,7 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For
if (!fs::exists(metadata_file)) continue; if (!fs::exists(metadata_file)) continue;
BundleMetadata metadata; BundleMetadata metadata;
if (!metadata.load_from_json(metadata_file.string())) continue; if (!load_bundle_metadata(metadata_file, metadata)) continue;
metadata.print_presets.clear(); metadata.print_presets.clear();
metadata.filament_presets.clear(); metadata.filament_presets.clear();
metadata.printer_presets.clear(); metadata.printer_presets.clear();
@@ -1609,10 +1618,12 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector<std::string>
if (ec) BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " create directory failed: " << ec.message(); if (ec) BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " create directory failed: " << ec.message();
//create temp folder //create temp folder
//std::string user_default_temp_dir = data_dir() + "/" + PRESET_USER_DIR + "/" + DEFAULT_USER_FOLDER_NAME + "/" + "temp"; //std::string user_default_temp_dir = data_dir() + "/" + PRESET_USER_DIR + "/" + DEFAULT_USER_FOLDER_NAME + "/" + "temp";
fs::path temp_folder(configs_folder / "temp"); // Under cache/, per process and per import, so two instances importing
// at once do not clear each other's extraction and no preset scan reads it.
static std::atomic<unsigned> import_counter{0};
fs::path temp_folder(fs::path(data_dir()) / "cache" / ("import." + std::to_string(get_current_pid()) + "." + std::to_string(import_counter++)));
std::string user_default_temp_dir = temp_folder.make_preferred().string(); std::string user_default_temp_dir = temp_folder.make_preferred().string();
if (fs::exists(temp_folder)) fs::remove_all(temp_folder); fs::create_directories(temp_folder, ec);
fs::create_directory(temp_folder, ec);
if (ec) BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " create directory failed: " << ec.message(); if (ec) BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " create directory failed: " << ec.message();
file = boost::filesystem::path(file).make_preferred().string(); file = boost::filesystem::path(file).make_preferred().string();
@@ -1624,6 +1635,9 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector<std::string>
status = mz_zip_reader_init_cfile(&zip_archive, zipFile, 0, MZ_ZIP_FLAG_CASE_SENSITIVE | MZ_ZIP_FLAG_IGNORE_PATH); status = mz_zip_reader_init_cfile(&zip_archive, zipFile, 0, MZ_ZIP_FLAG_CASE_SENSITIVE | MZ_ZIP_FLAG_IGNORE_PATH);
if (MZ_FALSE == status) { if (MZ_FALSE == status) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Failed to initialize reader ZIP archive"; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Failed to initialize reader ZIP archive";
if (zipFile != nullptr)
std::fclose(zipFile);
fs::remove_all(temp_folder, ec);
return substitutions; return substitutions;
} else { } else {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Success to initialize reader ZIP archive"; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Success to initialize reader ZIP archive";
@@ -2231,10 +2245,9 @@ void PresetBundle::remove_user_presets_directory(const std::string preset_folder
return; return;
} }
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(" enter, delete directory : %1%") % dir_user_presets; BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(" enter, delete directory : %1%") % dir_user_presets;
fs::path folder(dir_user_presets); boost::system::error_code ec;
if (fs::exists(folder)) { InstanceLock instance_lock(user_presets_lock_path());
fs::remove_all(folder); fs::remove_all(fs::path(dir_user_presets), ec);
}
} }
void PresetBundle::update_system_preset_setting_ids(std::map<std::string, std::map<std::string, std::string>>& system_presets) void PresetBundle::update_system_preset_setting_ids(std::map<std::string, std::map<std::string, std::string>>& system_presets)
@@ -7932,9 +7945,13 @@ bool BundleMetadata::save_to_json(const std::string& path) const
j["filament_presets"] = strip_prefix(this->filament_presets); j["filament_presets"] = strip_prefix(this->filament_presets);
j["printer_presets"] = strip_prefix(this->printer_presets); j["printer_presets"] = strip_prefix(this->printer_presets);
boost::nowide::ofstream ofs(path); const std::string content = j.dump(4);
ofs << j.dump(4); InstanceLock instance_lock(user_presets_lock_path());
return ofs.good(); if (const std::error_code ec = write_file_atomically(path, content)) {
BOOST_LOG_TRIVIAL(error) << "Failed to save bundle metadata to " << path << ": " << ec.message();
return false;
}
return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "Failed to save bundle metadata to " << path << ": " << e.what(); BOOST_LOG_TRIVIAL(error) << "Failed to save bundle metadata to " << path << ": " << e.what();
return false; return false;
+6 -28
View File
@@ -400,46 +400,24 @@ bool write_cache_blob(const std::string& path, const std::string& blob)
{ {
boost::crc_32_type crc; boost::crc_32_type crc;
crc.process_bytes(blob.data(), blob.size()); crc.process_bytes(blob.data(), blob.size());
// Written beside the target and moved into place, as AppConfig::save does: // Written beside the target and moved into place: a cache is truncated and
// a cache is truncated and rewritten in full, so a write that dies partway // rewritten in full, so a write that dies partway would otherwise leave a
// would otherwise leave a header claiming more body than the file holds. // header claiming more body than the file holds.
// The PID suffix also keeps two instances writing the same vendor from
// interleaving.
const std::string tmp_path = path + "." + std::to_string(get_current_pid()) + ".tmp";
try { try {
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path()); boost::filesystem::create_directories(boost::filesystem::path(path).parent_path());
{
boost::nowide::ofstream ofs(tmp_path, std::ios::binary | std::ios::trunc);
if (!ofs.is_open()) {
BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: cannot open for writing: " << tmp_path;
return false;
}
CacheFileHeader fhdr; CacheFileHeader fhdr;
fhdr.magic = CACHE_MAGIC; fhdr.magic = CACHE_MAGIC;
fhdr.version = CACHE_VERSION; fhdr.version = CACHE_VERSION;
fhdr.data_size = static_cast<uint64_t>(blob.size()); fhdr.data_size = static_cast<uint64_t>(blob.size());
fhdr.crc32 = crc.checksum(); fhdr.crc32 = crc.checksum();
ofs.write(reinterpret_cast<const char*>(&fhdr), sizeof(fhdr)); const std::string_view header(reinterpret_cast<const char*>(&fhdr), sizeof(fhdr));
ofs.write(blob.data(), static_cast<std::streamsize>(blob.size())); if (const std::error_code ec = write_file_atomically(path, { header, std::string_view(blob) }, /*binary=*/true)) {
ofs.close(); // flush; close() raises failbit on error BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: write failed (" << path << "): " << ec.message();
if (! ofs.good()) {
BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: write failed (" << tmp_path << ")";
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
return false;
}
}
if (const std::error_code ec = rename_file(tmp_path, path)) {
BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: could not move " << tmp_path << " into place: " << ec.message();
boost::system::error_code rm;
boost::filesystem::remove(tmp_path, rm);
return false; return false;
} }
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: write failed (" << path << "): " << e.what(); BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: write failed (" << path << "): " << e.what();
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
return false; return false;
} }
} }
+17
View File
@@ -8,6 +8,8 @@
#include <functional> #include <functional>
#include <type_traits> #include <type_traits>
#include <system_error> #include <system_error>
#include <initializer_list>
#include <string_view>
#include <regex> #include <regex>
#include <boost/system/error_code.hpp> #include <boost/system/error_code.hpp>
@@ -224,6 +226,21 @@ extern std::vector<std::string> split_string(const std::string &str, char delimi
// On Windows, the file explorer (or anti-virus or whatever else) often locks the file // On Windows, the file explorer (or anti-virus or whatever else) often locks the file
// for a short while, so the file may not be movable. Retry while we see recoverable errors. // for a short while, so the file may not be movable. Retry while we see recoverable errors.
extern std::error_code rename_file(const std::string &from, const std::string &to); extern std::error_code rename_file(const std::string &from, const std::string &to);
// Write `chunks`, in order, to `path` through a temporary file beside it that is
// then renamed over the target, so a concurrent reader sees the old or the new
// file, never a partial one. The temporary is removed on failure and an existing
// target keeps its permissions. Text mode unless `binary`, so Windows writes CRLF
// as the streams this replaces did. A target that is not a regular file (a
// device or pipe) is written in place, since replacing it would change what it
// is, and so is an existing target beside which no temporary can be created or
// whose replace the filesystem refuses; a symlink is followed and the file it
// names is replaced. On Windows a reader holding the
// target open without sharing its deletion, which the C runtime does not, makes
// the replace fall back to the in-place write too, so an unlocked reader there
// can still see a partial file.
extern std::error_code write_file_atomically(const std::string &path, std::initializer_list<std::string_view> chunks, bool binary = false);
inline std::error_code write_file_atomically(const std::string &path, const std::string &content, bool binary = false)
{ return write_file_atomically(path, { std::string_view(content) }, binary); }
enum CopyFileResult { enum CopyFileResult {
SUCCESS = 0, SUCCESS = 0,
+90 -2
View File
@@ -9,6 +9,8 @@
#include <stdio.h> #include <stdio.h>
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include <cerrno>
#include <mutex>
#include <iomanip> #include <iomanip>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@@ -702,13 +704,99 @@ namespace WindowsSupport
std::error_code rename_file(const std::string &from, const std::string &to) std::error_code rename_file(const std::string &from, const std::string &to)
{ {
#ifdef _WIN32 #ifdef _WIN32
// Retries and moves an open destination aside itself.
return WindowsSupport::rename(from, to); return WindowsSupport::rename(from, to);
#else #else
boost::nowide::remove(to.c_str()); // rename(2) replaces an existing target atomically; removing it first would
return std::make_error_code(static_cast<std::errc>(boost::nowide::rename(from.c_str(), to.c_str()))); // leave a window in which the file does not exist at all.
if (boost::nowide::rename(from.c_str(), to.c_str()) == 0)
return {};
const int err = errno;
// Some mounts (sshfs, gvfs, MTP and a few SMB setups) refuse to replace an
// existing target in one step, each with the error it sees fit; every error
// is worth the remove-then-rename this always did, except the ones no retry
// can help: nothing at the source, a different device, or a directory where
// a file was expected and the reverse.
const bool worth_retrying = err != ENOENT && err != EXDEV && err != ENOTDIR && err != EISDIR;
if (worth_retrying && boost::nowide::remove(to.c_str()) == 0 && boost::nowide::rename(from.c_str(), to.c_str()) == 0)
return {};
return std::make_error_code(static_cast<std::errc>(err));
#endif #endif
} }
static std::error_code write_whole_file(const std::string &path, std::initializer_list<std::string_view> chunks, bool binary)
{
errno = 0;
FILE *file = boost::nowide::fopen(path.c_str(), binary ? "wb" : "w");
if (file == nullptr)
return std::make_error_code(errno != 0 ? static_cast<std::errc>(errno) : std::errc::io_error);
bool ok = true;
for (const std::string_view chunk : chunks)
ok = ok && std::fwrite(chunk.data(), 1, chunk.size(), file) == chunk.size();
ok = ok && std::fflush(file) == 0;
const int err = ok ? 0 : errno;
ok = std::fclose(file) == 0 && ok;
if (ok)
return {};
return std::make_error_code(err != 0 ? static_cast<std::errc>(err) : std::errc::io_error);
}
// The in-place fallback truncates the target, so two threads of this process
// on the same file must not both be in it. One mutex for all such writes: they
// are the rare case. Never freed, like the InstanceLock registry, so a save
// during static destruction still finds it.
static std::error_code write_in_place(const std::string &path, std::initializer_list<std::string_view> chunks, bool binary)
{
static auto *mutex = new std::mutex();
std::lock_guard<std::mutex> guard(*mutex);
return write_whole_file(path, chunks, binary);
}
std::error_code write_file_atomically(const std::string &path, std::initializer_list<std::string_view> chunks, bool binary)
{
boost::system::error_code bec;
const boost::filesystem::file_status target = boost::filesystem::symlink_status(path, bec);
const bool target_exists = ! bec && boost::filesystem::exists(target);
if (target_exists && boost::filesystem::is_symlink(target)) {
// A config or preset kept in a dotfiles repository: the link stays,
// the file it points to is replaced like any other.
const boost::filesystem::path resolved = boost::filesystem::canonical(path, bec);
if (! bec && boost::filesystem::is_regular_file(resolved, bec))
return write_file_atomically(resolved.string(), chunks, binary);
}
if (target_exists && ! boost::filesystem::is_regular_file(target))
return write_in_place(path, chunks, binary);
// Unique per process and per call, so two threads writing one target
// without a lock never share a temporary.
static std::atomic<unsigned> counter{0};
const std::string tmp_path = path + "." + std::to_string(get_current_pid()) + "." + std::to_string(counter++) + ".tmp";
if (const std::error_code ec = write_whole_file(tmp_path, chunks, binary)) {
boost::nowide::remove(tmp_path.c_str());
if (! target_exists)
return ec;
// A directory that lets this process write its files but not create
// one: losing the save is worse than a reader seeing a partial file.
BOOST_LOG_TRIVIAL(warning) << "Cannot create a temporary beside " << path << " (" << ec.message() << "); writing in place";
return write_in_place(path, chunks, binary);
}
#ifndef _WIN32
// Not on Windows, where a read-only bit on the temporary would stop the rename itself.
if (target_exists)
boost::filesystem::permissions(tmp_path, target.permissions(), bec);
#endif
if (const std::error_code ec = rename_file(tmp_path, path)) {
boost::nowide::remove(tmp_path.c_str());
// A reader on Windows holding the target open without FILE_SHARE_DELETE,
// or a mount that cannot replace a file at all. Losing the save is worse
// than a reader seeing a partial file, so write in place the way this
// used to work before the atomic path existed.
BOOST_LOG_TRIVIAL(warning) << "Cannot replace " << path << " (" << ec.message() << "); writing in place";
return write_in_place(path, chunks, binary);
}
return {};
}
#ifdef __linux__ #ifdef __linux__
// Copied from boost::filesystem. // Copied from boost::filesystem.
// Called by copy_file_linux() in case linux sendfile() API is not supported. // Called by copy_file_linux() in case linux sendfile() API is not supported.
+10 -2
View File
@@ -139,8 +139,16 @@ set(SLIC3R_GUI_SOURCES
GUI/TerminalDialog.hpp GUI/TerminalDialog.hpp
GUI/PluginProgressDialog.cpp GUI/PluginProgressDialog.cpp
GUI/PluginProgressDialog.hpp GUI/PluginProgressDialog.hpp
GUI/PluginWebDialog.cpp GUI/WebDialog.cpp
GUI/PluginWebDialog.hpp GUI/WebDialog.hpp
GUI/DockPanel.cpp
GUI/DockPanel.hpp
GUI/WebPanel.cpp
GUI/WebPanel.hpp
GUI/Widgets/WebHosting.cpp
GUI/Widgets/WebHosting.hpp
GUI/AuiPaneLayout.cpp
GUI/AuiPaneLayout.hpp
GUI/DragCanvas.cpp GUI/DragCanvas.cpp
GUI/DragCanvas.hpp GUI/DragCanvas.hpp
GUI/EditGCodeDialog.cpp GUI/EditGCodeDialog.cpp
+20
View File
@@ -0,0 +1,20 @@
#include "AuiPaneLayout.hpp"
namespace Slic3r { namespace GUI {
std::string aui_pane_layout_entry(const std::string& layout, const std::string& pane_name)
{
// Panes are separated by '|'; SavePerspective() escapes a '|' inside a caption as "\|".
const std::string prefix = "name=" + pane_name + ";";
size_t begin = 0;
for (size_t i = 0; i <= layout.size(); ++i) {
if (i < layout.size() && (layout[i] != '|' || (i > 0 && layout[i - 1] == '\\')))
continue;
if (layout.compare(begin, prefix.size(), prefix) == 0)
return layout.substr(begin, i - begin);
begin = i + 1;
}
return {};
}
}} // namespace Slic3r::GUI
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <string>
namespace Slic3r { namespace GUI {
// The part a wxAuiManager layout string (wxAuiManager::SavePerspective) holds for `pane_name`, in the
// form wxAuiManager::LoadPaneInfo() takes, or empty when the layout has no such pane.
std::string aui_pane_layout_entry(const std::string& layout, const std::string& pane_name);
}} // namespace Slic3r::GUI
+103
View File
@@ -0,0 +1,103 @@
#include "DockPanel.hpp"
#include "GUI_App.hpp"
#include "Plater.hpp"
#include "Widgets/WebHosting.hpp"
#include <wx/weakref.h>
#include <algorithm>
#include <utility>
namespace Slic3r { namespace GUI {
std::string plugin_pane_name(const std::string& plugin_key, const std::string& title)
{
std::string name = "plugin:" + plugin_key + ":" + title;
std::replace_if(name.begin(), name.end(), [](char c) { return c == '|' || c == ';' || c == '=' || c == '\\'; }, '_');
return name;
}
DockPanel::DockPanel(wxWindow* parent,
const std::string& html,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed)
: WebPanel(parent, web_hosting::orca_bridge_script())
, m_html(html)
, m_on_message(std::move(on_message))
, m_on_close(std::move(on_close))
, m_on_destroyed(std::move(on_destroyed))
{
// A link asking for a new window has nowhere to open from a docked panel.
browser()->Bind(wxEVT_WEBVIEW_NEWWINDOW, [](wxWebViewEvent& event) { event.Veto(); });
}
DockPanel::~DockPanel()
{
if (m_on_destroyed)
m_on_destroyed();
}
bool DockPanel::on_page_message(const std::string& kind, const nlohmann::json& data)
{
if (kind == "message") {
if (m_on_message)
m_on_message(data);
return true;
}
if (kind == "close") {
request_close();
return true;
}
return false;
}
void DockPanel::push_message(const nlohmann::json& data)
{
if (!m_closing)
post_to_page(data.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
}
void DockPanel::fire_close()
{
if (m_closing)
return;
m_closing = true;
if (m_on_close) {
CloseHandler on_close = std::move(m_on_close);
m_on_close = nullptr;
on_close();
}
}
void DockPanel::request_close()
{
if (m_closing)
return;
fire_close();
// A page-requested close arrives inside the web view's script callback, so destroy later; another
// close path may have destroyed the panel by then.
wxWeakRef<DockPanel> self(this);
CallAfter([self]() {
if (self)
self->remove_pane();
});
}
void DockPanel::destroy_silently()
{
m_closing = true;
m_on_close = nullptr;
remove_pane();
}
void DockPanel::remove_pane()
{
if (Plater* plater = wxGetApp().plater())
plater->remove_dock_pane(this);
else
Destroy();
}
}} // namespace Slic3r::GUI
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "WebPanel.hpp"
#include <functional>
#include <string>
namespace Slic3r { namespace GUI {
// Stable across sessions so the saved layout finds the pane; free of wxAuiManager layout delimiters.
std::string plugin_pane_name(const std::string& plugin_key, const std::string& title);
// A WebPanel docked in the Plater, on the plugin-window bridge minus submit. It can be destroyed
// without the GIL, so its hooks must not capture pybind11 objects.
class DockPanel : public WebPanel
{
public:
using MessageHandler = std::function<void(const nlohmann::json& data)>;
using CloseHandler = std::function<void()>;
// on_close fires once, on a user or page close. on_destroyed runs on every destruction and must
// touch host-side state only.
DockPanel(wxWindow* parent,
const std::string& html,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed);
~DockPanel() override;
// Main thread only.
void push_message(const nlohmann::json& data);
// Fires on_close, then removes the pane.
void request_close();
// Removes the pane without on_close, for plugin unload. Destroys at once: unload always comes from
// the host, never from this panel's own callbacks.
void destroy_silently();
// Fires on_close at most once; also run by the pane's own close button.
void fire_close();
protected:
std::optional<std::string> page_html() override { return m_html; }
bool on_page_message(const std::string& kind, const nlohmann::json& data) override;
private:
void remove_pane();
std::string m_html;
bool m_closing{false};
MessageHandler m_on_message;
CloseHandler m_on_close;
CloseHandler m_on_destroyed;
};
}} // namespace Slic3r::GUI
+15 -6
View File
@@ -85,6 +85,7 @@
#include "libslic3r/Model.hpp" #include "libslic3r/Model.hpp"
#include "libslic3r/I18N.hpp" #include "libslic3r/I18N.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "libslic3r/InstanceLock.hpp"
#include "libslic3r/Thread.hpp" #include "libslic3r/Thread.hpp"
#include "libslic3r/miniz_extension.hpp" #include "libslic3r/miniz_extension.hpp"
#include "libslic3r/Utils.hpp" #include "libslic3r/Utils.hpp"
@@ -3531,7 +3532,7 @@ bool GUI_App::on_init_inner()
update_publish_status(); update_publish_status();
} }
if (m_post_initialized && app_config->dirty()) if (m_post_initialized && app_config->dirty() && app_config->save_due())
app_config->save(); app_config->save();
}); });
@@ -7623,8 +7624,11 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg)
// Delete the bundle folder and bundle // Delete the bundle folder and bundle
fs::path bundle_folder = fs::path(bundle.path.c_str()).parent_path(); fs::path bundle_folder = fs::path(bundle.path.c_str()).parent_path();
{
boost::system::error_code ec; boost::system::error_code ec;
InstanceLock instance_lock(user_presets_lock_path());
boost::filesystem::remove_all(bundle_folder, ec); boost::filesystem::remove_all(bundle_folder, ec);
}
preset_bundle->bundles.WriteLock(); preset_bundle->bundles.WriteLock();
preset_bundle->bundles.m_bundles.erase(bundle.id); preset_bundle->bundles.m_bundles.erase(bundle.id);
@@ -8889,6 +8893,7 @@ void GUI_App::preset_deleted_from_cloud(std::string setting_id)
// Delete the .info file after cloud deletion is confirmed // Delete the .info file after cloud deletion is confirmed
if (!preset_file_path.empty() && fs::exists(fs::path(preset_file_path))) { if (!preset_file_path.empty() && fs::exists(fs::path(preset_file_path))) {
InstanceLock instance_lock(user_presets_lock_path());
boost::nowide::remove(preset_file_path.c_str()); boost::nowide::remove(preset_file_path.c_str());
BOOST_LOG_TRIVIAL(info) << "Deleted .info file after cloud confirmation: " << preset_file_path; BOOST_LOG_TRIVIAL(info) << "Deleted .info file after cloud confirmation: " << preset_file_path;
} }
@@ -8951,17 +8956,21 @@ void GUI_App::scan_orphaned_info_files()
fs::path preset_file = info_file; fs::path preset_file = info_file;
preset_file.replace_extension(".json"); preset_file.replace_extension(".json");
// If .json doesn't exist, .info is orphaned // If .json doesn't exist, .info is orphaned. Read under the lock, so a
if (!fs::exists(preset_file)) { // remove_files() in another instance is seen whole or not at all; the
// Extract setting_id from .info file // delete queue's own mutex is taken after the lock is released.
std::string setting_id = extract_setting_id_from_info(info_file.string()); std::string setting_id;
{
InstanceLock instance_lock(user_presets_lock_path());
if (!fs::exists(preset_file))
setting_id = extract_setting_id_from_info(info_file.string());
}
if (!setting_id.empty()) { if (!setting_id.empty()) {
// Add to need_delete_presets // Add to need_delete_presets
delete_preset_from_cloud(setting_id, info_file.string()); delete_preset_from_cloud(setting_id, info_file.string());
BOOST_LOG_TRIVIAL(info) << "Found orphaned .info file on startup: " << info_file.string(); BOOST_LOG_TRIVIAL(info) << "Found orphaned .info file on startup: " << info_file.string();
} }
} }
}
if (ec) if (ec)
BOOST_LOG_TRIVIAL(warning) << "scan_orphaned_info_files: failed to scan " << type_dir.string() << ": " << ec.message(); BOOST_LOG_TRIVIAL(warning) << "scan_orphaned_info_files: failed to scan " << type_dir.string() << ": " << ec.message();
} }
+2
View File
@@ -1189,6 +1189,8 @@ void MainFrame::shutdown()
if (m_project != nullptr) if (m_project != nullptr)
m_project->shutdown(); m_project->shutdown();
m_plugin_pages.shutdown(); m_plugin_pages.shutdown();
if (m_plater != nullptr)
m_plater->remove_dock_panes();
#ifdef __WXGTK__ #ifdef __WXGTK__
// Edge panels are child windows — wxWidgets destroys them automatically. // Edge panels are child windows — wxWidgets destroys them automatically.
m_edge_bottom = nullptr; m_edge_bottom = nullptr;
+133
View File
@@ -87,6 +87,7 @@
#ifdef __WXGTK__ #ifdef __WXGTK__
#include "LinuxDisplayBackend.hpp" #include "LinuxDisplayBackend.hpp"
#endif #endif
#include "AuiPaneLayout.hpp"
#include "GUI_Utils.hpp" #include "GUI_Utils.hpp"
#include "GUI_Factories.hpp" #include "GUI_Factories.hpp"
#include "wxExtensions.hpp" #include "wxExtensions.hpp"
@@ -6748,6 +6749,14 @@ struct Plater::priv
// GUI elements // GUI elements
AuiMgr m_aui_mgr; AuiMgr m_aui_mgr;
// Live dock panes. `on_close` runs when the user closes one from its close button; `shown` is
// what the owner asked for.
struct DockPane
{
std::function<void()> on_close;
bool shown{true};
};
std::map<wxWindow*, DockPane> m_dock_panes;
wxString m_default_window_layout; wxString m_default_window_layout;
wxPanel* current_panel{ nullptr }; wxPanel* current_panel{ nullptr };
std::vector<wxPanel*> panels; std::vector<wxPanel*> panels;
@@ -6920,6 +6929,11 @@ struct Plater::priv
void update_sidebar(bool force_update = false); void update_sidebar(bool force_update = false);
void reset_window_layout(); void reset_window_layout();
Sidebar::DockingState get_sidebar_docking_state(); Sidebar::DockingState get_sidebar_docking_state();
void add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close);
void remove_dock_pane(wxWindow* window);
void show_dock_pane(wxWindow* window, bool show);
bool dock_pane_visible(const DockPane& dock_pane, const wxAuiPaneInfo& pane) const;
bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); } bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); }
@@ -7500,6 +7514,18 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
panel_3d->SetSizer(panel_sizer); panel_3d->SetSizer(panel_sizer);
m_aui_mgr.AddPane(panel_3d, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false)); m_aui_mgr.AddPane(panel_3d, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false));
q->Bind(wxEVT_AUI_PANE_CLOSE, [this](wxAuiManagerEvent& evt) {
const wxAuiPaneInfo* pane = evt.GetPane();
auto it = pane != nullptr ? m_dock_panes.find(pane->window) : m_dock_panes.end();
if (it != m_dock_panes.end()) {
const std::function<void()> on_close = std::move(it->second.on_close);
m_dock_panes.erase(it);
if (on_close)
on_close();
}
evt.Skip();
});
m_default_window_layout = m_aui_mgr.SavePerspective(); m_default_window_layout = m_aui_mgr.SavePerspective();
{ {
@@ -8165,6 +8191,14 @@ void Plater::priv::update_sidebar(bool force_update) {
} }
} }
for (const auto& [window, dock_pane] : m_dock_panes) {
wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
if (pane.IsOk() && pane.IsShown() != dock_pane_visible(dock_pane, pane)) {
pane.Show(!pane.IsShown());
needs_update = true;
}
}
if (needs_update) { if (needs_update) {
notification_manager->set_sidebar_collapsed(sidebar.IsShown()); notification_manager->set_sidebar_collapsed(sidebar.IsShown());
m_aui_mgr.Update(); m_aui_mgr.Update();
@@ -8174,10 +8208,96 @@ void Plater::priv::update_sidebar(bool force_update) {
void Plater::priv::reset_window_layout() void Plater::priv::reset_window_layout()
{ {
m_aui_mgr.LoadPerspective(m_default_window_layout, false); m_aui_mgr.LoadPerspective(m_default_window_layout, false);
// Loading a layout docks and hides every pane it does not list, and the default layout lists no
// dock panes: a floating dock pane is docked again, like the rest of the window.
for (const auto& [window, dock_pane] : m_dock_panes)
if (wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); pane.IsOk())
pane.Show(dock_pane_visible(dock_pane, pane));
sidebar_layout.is_collapsed = false; sidebar_layout.is_collapsed = false;
update_sidebar(true); update_sidebar(true);
} }
bool Plater::priv::dock_pane_visible(const DockPane& dock_pane, const wxAuiPaneInfo& pane) const
{
// A floating pane is a top-level window, so it does not hide with the Plater on other tabs.
return dock_pane.shown && (!pane.IsFloating() || sidebar_layout.show);
}
void Plater::priv::add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close)
{
const wxString base_name = wxString::FromUTF8(name);
wxString unique_name = base_name;
for (int i = 2; m_aui_mgr.GetPane(unique_name).IsOk(); ++i)
unique_name = base_name + wxString::Format("#%d", i);
// A restored layout below already holds pixels.
const wxSize pixels = q->FromDIP(size);
wxAuiPaneInfo info;
info.Name(unique_name).Caption(caption).BestSize(pixels).FloatingSize(pixels).DestroyOnClose(true);
if (dock == "left")
info.Left();
else if (dock == "bottom")
info.Bottom();
else
info.Right();
if (dock == "float")
info.Float();
// Put the pane back where it was the last time the window layout was saved with it open.
const std::string saved = aui_pane_layout_entry(wxGetApp().app_config->get("window_layout"), unique_name.utf8_string());
if (!saved.empty()) {
m_aui_mgr.LoadPaneInfo(wxString::FromUTF8(saved), info);
info.Caption(caption).DestroyOnClose(true).Show();
}
// Floating is disabled on Wayland.
if ((m_aui_mgr.GetFlags() & wxAUI_MGR_ALLOW_FLOATING) == 0) {
info.Dock().Floatable(false);
if (info.dock_direction == wxAUI_DOCK_NONE)
info.Right();
}
const DockPane& dock_pane = m_dock_panes[window] = DockPane{std::move(on_close)};
info.Show(dock_pane_visible(dock_pane, info));
m_aui_mgr.AddPane(window, info);
// wxAUI does not record a dragged sash in best_size, so track the docked size like the sidebar
// does, for the saved layout.
window->Bind(wxEVT_IDLE, [this, window](wxIdleEvent& evt) {
wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
if (pane.IsOk() && pane.IsShown() && pane.IsDocked() && pane.rect.GetWidth() > 0 && pane.rect.GetHeight() > 0) {
const bool horizontal = pane.dock_direction == wxAUI_DOCK_TOP || pane.dock_direction == wxAUI_DOCK_BOTTOM;
pane.BestSize(horizontal ? pane.best_size.GetWidth() : pane.rect.GetWidth(),
horizontal ? pane.rect.GetHeight() : pane.best_size.GetHeight());
}
evt.Skip();
});
m_aui_mgr.Update();
}
void Plater::priv::remove_dock_pane(wxWindow* window)
{
m_dock_panes.erase(window);
if (m_aui_mgr.DetachPane(window))
m_aui_mgr.Update();
window->Destroy();
}
void Plater::priv::show_dock_pane(wxWindow* window, bool show)
{
const auto it = m_dock_panes.find(window);
wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
if (it == m_dock_panes.end() || !pane.IsOk())
return;
it->second.shown = show;
if (pane.IsShown() == dock_pane_visible(it->second, pane))
return;
pane.Show(!pane.IsShown());
m_aui_mgr.Update();
}
Sidebar::DockingState Plater::priv::get_sidebar_docking_state() { Sidebar::DockingState Plater::priv::get_sidebar_docking_state() {
if (!sidebar_layout.is_enabled) { if (!sidebar_layout.is_enabled) {
return Sidebar::None; return Sidebar::None;
@@ -17772,6 +17892,19 @@ Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_
void Plater::reset_window_layout() { p->reset_window_layout(); } void Plater::reset_window_layout() { p->reset_window_layout(); }
void Plater::add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close)
{
p->add_dock_pane(window, name, caption, dock, size, std::move(on_close));
}
void Plater::remove_dock_pane(wxWindow* window) { p->remove_dock_pane(window); }
void Plater::remove_dock_panes()
{
while (!p->m_dock_panes.empty())
p->remove_dock_pane(p->m_dock_panes.begin()->first);
}
void Plater::show_dock_pane(wxWindow* window, bool show) { p->show_dock_pane(window, show); }
//BBS //BBS
void Plater::select_curr_plate_all() { p->select_curr_plate_all(); } void Plater::select_curr_plate_all() { p->select_curr_plate_all(); }
void Plater::remove_curr_plate_all() { p->remove_curr_plate_all(); } void Plater::remove_curr_plate_all() { p->remove_curr_plate_all(); }
+11
View File
@@ -476,6 +476,17 @@ public:
void reset_window_layout(); void reset_window_layout();
// Dock panes sit alongside the sidebar; `window` must be a child of the Plater. `dock` is
// "left", "right", "bottom" or "float", and `size` is in DIPs. A pane closed from its own close
// button is destroyed after on_close runs; remove_dock_pane() destroys it without calling on_close.
void add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
const wxSize& size, std::function<void()> on_close);
void remove_dock_pane(wxWindow* window);
void show_dock_pane(wxWindow* window, bool show);
// Removes every dock pane without calling on_close, for MainFrame::shutdown() (app exit and a
// language switch), while the Plater and any floating frames still exist.
void remove_dock_panes();
// Called after the Preferences dialog is closed and the program settings are saved. // Called after the Preferences dialog is closed and the program settings are saved.
// Update the UI based on the current preferences. // Update the UI based on the current preferences.
void update_ui_from_settings(); void update_ui_from_settings();
@@ -1,72 +1,15 @@
#include "PluginWebDialog.hpp" #include "WebDialog.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Widgets/WebHosting.hpp"
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <wx/event.h> #include <wx/event.h>
#include <wx/uri.h>
#include <utility> #include <utility>
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
namespace { WebDialog::WebDialog(wxWindow* parent,
// Injected into the top-level page at document start (before the plugin's own
// scripts). Defines window.orca as the only host surface the page may use. It
// references window.wx lazily (at call time) so it never races the backend's
// deferred registration of the "wx" message handler. Guarded against
// double-injection so it is harmless if also prepended.
constexpr char ORCA_BRIDGE_JS[] = R"JS(
(function () {
if (window.top !== window.self) return;
if (window.orca) return;
var handlers = [];
function send(kind, data) {
try {
window.wx.postMessage(JSON.stringify({
channel: 'orca', kind: kind, data: (data === undefined ? null : data)
}));
} catch (e) { /* bridge not ready yet */ }
}
window.orca = {
postMessage: function (d) { send('message', d); },
submit: function (d) { send('submit', d); },
close: function () { send('close'); },
onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); }
};
window.__orcaDispatch = function (payload) {
var data = payload ? payload.data : null;
for (var i = 0; i < handlers.length; i++) {
try { handlers[i](data); } catch (e) {}
}
};
})();
)JS";
// file:// base URL for plugin HTML loaded via SetPage, so self-referencing
// relative URLs resolve against the bundled web resources directory.
wxString web_base_url()
{
const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
return wxString("file://") + from_u8(dir) + "/";
}
// Whether a loaded document is the plugin HTML's own base URL. The web view reports the URL it
// parsed, so any fragment the page navigated to is ignored and the escaping it applies to what the
// resources path holds (a space, a non-ASCII character) is undone first.
bool is_content_url(const wxString& url)
{
return wxURI::Unescape(url.BeforeFirst('#')) == web_base_url();
}
} // namespace
PluginWebDialog::PluginWebDialog(wxWindow* parent,
const wxString& title, const wxString& title,
const std::string& html, const std::string& html,
const wxSize& size, const wxSize& size,
@@ -84,7 +27,7 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
{ {
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML // A tiny bundled bootstrap page brings the webview up; the real plugin HTML
// is swapped in via SetPage once the bootstrap finishes loading. // is swapped in via SetPage once the bootstrap finishes loading.
create_webview("web/dialog/PluginWebDialog/blank.html", title, size, wxSize(320, 240)); create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
// Paint the window/webview in the themed background so there is no white // Paint the window/webview in the themed background so there is no white
// flash before the (transparent) bootstrap page and plugin HTML render. // flash before the (transparent) bootstrap page and plugin HTML render.
@@ -96,22 +39,22 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
// create_webview() via add_user_scripts(); nothing to add here. // create_webview() via add_user_scripts(); nothing to add here.
// Swap in the plugin HTML once the bootstrap page settles. Bind ERROR too so a // Swap in the plugin HTML once the bootstrap page settles. Bind ERROR too so a
// missing/blocked bootstrap resource (e.g. a packaged build) still triggers it. // missing/blocked bootstrap resource (e.g. a packaged build) still triggers it.
Bind(wxEVT_WEBVIEW_LOADED, &PluginWebDialog::on_bootstrap_event, this, wv->GetId()); Bind(wxEVT_WEBVIEW_LOADED, &WebDialog::on_bootstrap_event, this, wv->GetId());
Bind(wxEVT_WEBVIEW_ERROR, &PluginWebDialog::on_bootstrap_event, this, wv->GetId()); Bind(wxEVT_WEBVIEW_ERROR, &WebDialog::on_bootstrap_event, this, wv->GetId());
Bind(wxEVT_WEBVIEW_NAVIGATED, &PluginWebDialog::on_navigated, this, wv->GetId()); Bind(wxEVT_WEBVIEW_NAVIGATED, &WebDialog::on_navigated, this, wv->GetId());
} }
Bind(wxEVT_CLOSE_WINDOW, &PluginWebDialog::on_close_window, this); Bind(wxEVT_CLOSE_WINDOW, &WebDialog::on_close_window, this);
} }
void PluginWebDialog::add_user_scripts() void WebDialog::add_user_scripts()
{ {
if (wxWebView* wv = browser()) { if (wxWebView* wv = browser()) {
wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script())); wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
wv->AddUserScript(ORCA_BRIDGE_JS); wv->AddUserScript(wxString::FromUTF8(web_hosting::orca_bridge_script()));
} }
} }
PluginWebDialog::~PluginWebDialog() WebDialog::~WebDialog()
{ {
// Runs on every destruction path. Deliberately NOT a wxEVT_DESTROY handler: // Runs on every destruction path. Deliberately NOT a wxEVT_DESTROY handler:
// that event is sent from the base ~wxDialog(), after this subclass's members // that event is sent from the base ~wxDialog(), after this subclass's members
@@ -121,19 +64,19 @@ PluginWebDialog::~PluginWebDialog()
m_on_destroyed(); m_on_destroyed();
} }
void PluginWebDialog::post_message(PluginWebDialog* dialog, const nlohmann::json& data) void WebDialog::post_message(WebDialog* dialog, const nlohmann::json& data)
{ {
if (dialog != nullptr && dialog->is_open()) if (dialog != nullptr && dialog->is_open())
dialog->push_message(data); dialog->push_message(data);
} }
void PluginWebDialog::request_close(PluginWebDialog* dialog) void WebDialog::request_close(WebDialog* dialog)
{ {
if (dialog != nullptr) if (dialog != nullptr)
dialog->Close(); dialog->Close();
} }
void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog) void WebDialog::destroy_silently(WebDialog* dialog)
{ {
if (dialog == nullptr) if (dialog == nullptr)
return; return;
@@ -147,42 +90,42 @@ void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog)
dialog->Destroy(); dialog->Destroy();
} }
void PluginWebDialog::on_bootstrap_event(wxWebViewEvent& event) void WebDialog::on_bootstrap_event(wxWebViewEvent& event)
{ {
const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED; const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED;
// The first bootstrap load (or its error) triggers the swap to plugin HTML. // The first bootstrap load (or its error) triggers the swap to plugin HTML.
if (!m_content_loaded) if (!m_content_loaded)
load_plugin_content(); load_page_html();
// WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a reload. // WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a reload.
// A failed navigation is reported against the page that stayed but never commits. Edge ignores the // A failed navigation is reported against the page that stayed but never commits. Edge ignores the
// base URL and restores SetPage content itself, so nothing matches there. // base URL and restores SetPage content itself, so nothing matches there.
else if (is_content_url(event.GetURL())) { else if (web_hosting::is_content_url(event.GetURL())) {
if (m_own_page_load) if (m_own_page_load)
m_own_page_load = false; m_own_page_load = false;
else if (loaded && m_content_navigated) else if (loaded && m_content_navigated)
load_plugin_content(); load_page_html();
} }
if (loaded) if (loaded)
m_content_navigated = false; m_content_navigated = false;
event.Skip(); event.Skip();
} }
void PluginWebDialog::on_navigated(wxWebViewEvent& event) void WebDialog::on_navigated(wxWebViewEvent& event)
{ {
m_content_navigated = is_content_url(event.GetURL()); m_content_navigated = web_hosting::is_content_url(event.GetURL());
event.Skip(); event.Skip();
} }
void PluginWebDialog::load_plugin_content() void WebDialog::load_page_html()
{ {
m_content_loaded = true; m_content_loaded = true;
if (wxWebView* wv = browser()) { if (wxWebView* wv = browser()) {
m_own_page_load = true; m_own_page_load = true;
wv->SetPage(wxString::FromUTF8(m_html), web_base_url()); wv->SetPage(wxString::FromUTF8(m_html), web_hosting::content_base_url());
} }
} }
void PluginWebDialog::on_script_message(const nlohmann::json& payload) void WebDialog::on_script_message(const nlohmann::json& payload)
{ {
if (payload.value("channel", std::string()) == "orca") { if (payload.value("channel", std::string()) == "orca") {
const std::string kind = payload.value("kind", std::string()); const std::string kind = payload.value("kind", std::string());
@@ -202,7 +145,7 @@ void PluginWebDialog::on_script_message(const nlohmann::json& payload)
handle_common_script_command(payload); handle_common_script_command(payload);
} }
void PluginWebDialog::push_message(const nlohmann::json& data) void WebDialog::push_message(const nlohmann::json& data)
{ {
if (!m_open) if (!m_open)
return; return;
@@ -211,7 +154,7 @@ void PluginWebDialog::push_message(const nlohmann::json& data)
call_web_handler(envelope, wxT("__orcaDispatch")); call_web_handler(envelope, wxT("__orcaDispatch"));
} }
void PluginWebDialog::finish(bool submitted, const nlohmann::json& data) void WebDialog::finish(bool submitted, const nlohmann::json& data)
{ {
if (!m_open) if (!m_open)
return; return;
@@ -230,7 +173,7 @@ void PluginWebDialog::finish(bool submitted, const nlohmann::json& data)
Close(); Close();
} }
void PluginWebDialog::on_close_window(wxCloseEvent&) void WebDialog::on_close_window(wxCloseEvent&)
{ {
if (!m_open) { if (!m_open) {
// finish() already dispatched submit/close and requested the close. // finish() already dispatched submit/close and requested the close.
@@ -250,7 +193,7 @@ void PluginWebDialog::on_close_window(wxCloseEvent&)
Destroy(); Destroy();
} }
void PluginWebDialog::fire_submit(const nlohmann::json& data) void WebDialog::fire_submit(const nlohmann::json& data)
{ {
if (m_on_submit) { if (m_on_submit) {
SubmitHandler cb = std::move(m_on_submit); SubmitHandler cb = std::move(m_on_submit);
@@ -258,7 +201,7 @@ void PluginWebDialog::fire_submit(const nlohmann::json& data)
} }
} }
void PluginWebDialog::fire_close() void WebDialog::fire_close()
{ {
if (m_close_fired) if (m_close_fired)
return; return;
@@ -1,5 +1,5 @@
#ifndef slic3r_GUI_PluginWebDialog_hpp_ #ifndef slic3r_GUI_WebDialog_hpp_
#define slic3r_GUI_PluginWebDialog_hpp_ #define slic3r_GUI_WebDialog_hpp_
#include "Widgets/WebViewHostDialog.hpp" #include "Widgets/WebViewHostDialog.hpp"
@@ -21,7 +21,7 @@ namespace Slic3r { namespace GUI {
// GIL held; the plugin layer wraps any Python callables in a GIL-safe holder. // GIL held; the plugin layer wraps any Python callables in a GIL-safe holder.
// //
// Usable both modally (ShowModal -> read result()) and modelessly (Show()). // Usable both modally (ShowModal -> read result()) and modelessly (Show()).
class PluginWebDialog : public Slic3r::GUI::WebViewHostDialog class WebDialog : public Slic3r::GUI::WebViewHostDialog
{ {
public: public:
using MessageHandler = std::function<void(const nlohmann::json& data)>; using MessageHandler = std::function<void(const nlohmann::json& data)>;
@@ -32,7 +32,7 @@ public:
// user/JS-initiated close (while the window is alive). on_destroyed runs from // user/JS-initiated close (while the window is alive). on_destroyed runs from
// the destructor on every path and must touch host-side state only (no Python // the destructor on every path and must touch host-side state only (no Python
// / no derived members). // / no derived members).
PluginWebDialog(wxWindow* parent, WebDialog(wxWindow* parent,
const wxString& title, const wxString& title,
const std::string& html, const std::string& html,
const wxSize& size, const wxSize& size,
@@ -41,11 +41,11 @@ public:
CloseHandler on_close, CloseHandler on_close,
CloseHandler on_destroyed, CloseHandler on_destroyed,
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER); long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
~PluginWebDialog() override; ~WebDialog() override;
static void post_message(PluginWebDialog* dialog, const nlohmann::json& data); static void post_message(WebDialog* dialog, const nlohmann::json& data);
static void request_close(PluginWebDialog* dialog); static void request_close(WebDialog* dialog);
static void destroy_for_plugin(PluginWebDialog* dialog); static void destroy_silently(WebDialog* dialog);
// Push a payload to the page; delivered to handlers registered via // Push a payload to the page; delivered to handlers registered via
// window.orca.onMessage(). MAIN-THREAD ONLY (the plugin layer marshals). // window.orca.onMessage(). MAIN-THREAD ONLY (the plugin layer marshals).
@@ -65,7 +65,7 @@ protected:
private: private:
void on_bootstrap_event(wxWebViewEvent& event); void on_bootstrap_event(wxWebViewEvent& event);
void on_navigated(wxWebViewEvent& event); void on_navigated(wxWebViewEvent& event);
void load_plugin_content(); void load_page_html();
void on_close_window(wxCloseEvent& event); void on_close_window(wxCloseEvent& event);
void fire_submit(const nlohmann::json& data); void fire_submit(const nlohmann::json& data);
void fire_close(); void fire_close();
@@ -86,4 +86,4 @@ private:
}} // namespace Slic3r::GUI }} // namespace Slic3r::GUI
#endif // slic3r_GUI_PluginWebDialog_hpp_ #endif // slic3r_GUI_WebDialog_hpp_
+2 -13
View File
@@ -1481,9 +1481,7 @@ bool GuideFrame::BuildProfileDataFromVendors()
return false; return false;
// Written through a temp file and moved into place, as the preset caches // Written through a temp file and moved into place, as the preset caches
// are: half a cache must never be readable, and the PID suffix keeps two // are: half a cache must never be readable.
// instances from interleaving on one temp file.
const std::string tmp_path = cache_file.string() + "." + std::to_string(get_current_pid()) + ".tmp";
try { try {
json out; json out;
out["format"] = 1; out["format"] = 1;
@@ -1492,18 +1490,9 @@ bool GuideFrame::BuildProfileDataFromVendors()
for (const char* key : { "model", "machine", "filament", "process" }) for (const char* key : { "model", "machine", "filament", "process" })
profile[key] = m_ProfileJson[key]; profile[key] = m_ProfileJson[key];
boost::filesystem::create_directories(cache_file.parent_path()); boost::filesystem::create_directories(cache_file.parent_path());
{ if (const std::error_code ec = write_file_atomically(cache_file.string(), out.dump(-1, ' ', false, json::error_handler_t::ignore), /*binary=*/true))
boost::nowide::ofstream ofs(tmp_path, std::ios::binary | std::ios::trunc);
ofs << out.dump(-1, ' ', false, json::error_handler_t::ignore);
ofs.close();
if (! ofs.good())
throw std::runtime_error("write failed");
}
if (const std::error_code ec = rename_file(tmp_path, cache_file.string()))
throw std::runtime_error(ec.message()); throw std::runtime_error(ec.message());
} catch (const std::exception& e) { } catch (const std::exception& e) {
boost::system::error_code rm;
boost::filesystem::remove(tmp_path, rm);
BOOST_LOG_TRIVIAL(warning) << "GuideFrame: could not write the profile data cache: " << e.what(); BOOST_LOG_TRIVIAL(warning) << "GuideFrame: could not write the profile data cache: " << e.what();
} }
return true; return true;
+111
View File
@@ -0,0 +1,111 @@
#include "WebPanel.hpp"
#include "GUI_App.hpp"
#include "Widgets/WebHosting.hpp"
#include "Widgets/WebView.hpp"
#include "Widgets/WebViewHostDialog.hpp"
#include <boost/log/trivial.hpp>
#include <wx/sizer.h>
namespace Slic3r { namespace GUI {
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script)
: wxPanel(parent, wxID_ANY)
{
SetBackgroundColour(wxGetApp().get_window_default_clr());
auto* sizer = new wxBoxSizer(wxVERTICAL);
SetSizer(sizer);
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url());
m_browser->SetBackgroundColour(GetBackgroundColour());
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(bridge_script));
m_browser->Bind(wxEVT_WEBVIEW_LOADED, &WebPanel::on_load_event, this);
m_browser->Bind(wxEVT_WEBVIEW_ERROR, &WebPanel::on_load_event, this);
m_browser->Bind(wxEVT_WEBVIEW_NAVIGATED, &WebPanel::on_navigated, this);
m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebPanel::on_script_message, this);
m_browser->Bind(EVT_WEBVIEW_RECREATED, &WebPanel::on_webview_recreated, this);
sizer->Add(m_browser, 1, wxEXPAND);
}
void WebPanel::on_load_event(wxWebViewEvent& event)
{
const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED;
if (!m_content_loaded) {
// The first bootstrap load (or its error) triggers the swap to the plugin HTML.
m_content_loaded = true;
load_page_html();
} else if (!web_hosting::is_content_url(event.GetURL())) {
// Not our document (a linked page, a substituted error page), or any document on Edge, which ignores
// the base URL and restores SetPage content on a reload itself; either way it takes the app theme.
if (loaded)
apply_theme();
} else if (m_own_page_load) {
m_own_page_load = false;
// The document-start theme script is fixed at creation, so re-apply the app theme.
if (loaded)
apply_theme();
} else if (loaded && m_content_navigated) {
// WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a
// reload. A failed navigation is reported against the page that stayed but never commits.
load_page_html();
}
if (loaded)
m_content_navigated = false;
event.Skip();
}
void WebPanel::on_navigated(wxWebViewEvent& event)
{
m_content_navigated = web_hosting::is_content_url(event.GetURL());
event.Skip();
}
void WebPanel::load_page_html()
{
if (const std::optional<std::string> html = page_html()) {
m_own_page_load = true;
m_browser->SetPage(wxString::FromUTF8(*html), web_hosting::content_base_url());
}
}
void WebPanel::on_script_message(wxWebViewEvent& event)
{
const nlohmann::json payload = nlohmann::json::parse(event.GetString().utf8_string(), nullptr, false);
if (!payload.is_object() || payload.value("channel", std::string()) != "orca")
return;
const std::string kind = payload.value("kind", std::string());
if (!on_page_message(kind, payload.contains("data") ? payload["data"] : nlohmann::json()))
BOOST_LOG_TRIVIAL(warning) << "WebPanel ignored a window.orca '" << kind << "' call; this host does not support it";
}
void WebPanel::on_webview_recreated(wxCommandEvent&)
{
SetBackgroundColour(wxGetApp().get_window_default_clr());
m_browser->SetBackgroundColour(GetBackgroundColour());
Refresh();
// Handled without Skip(), so WebView::RecreateAll() does not reload the plugin page.
apply_theme();
}
void WebPanel::apply_theme()
{
WebView::RunScript(m_browser, wxString::FromUTF8(WebViewHostDialog::theme_apply_script()));
}
void WebPanel::post_to_page(const std::string& json)
{
WebView::RunScript(m_browser, wxString::Format(
"(function dispatch(payload, attempts) {\n"
" if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n"
" if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n"
"})({data: %s}, 0);",
wxString::FromUTF8(json)));
}
}} // namespace Slic3r::GUI
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <nlohmann/json.hpp>
#include <wx/panel.h>
#include <wx/webview.h>
#include <optional>
#include <string>
namespace Slic3r { namespace GUI {
// Host-supplied HTML in a web view panel, for any window that embeds or derives from it (today plugin
// Pages tabs and docked panels): bootstrap page and swap, theme and bridge scripts, live re-theming, and
// window.orca messages routed to on_page_message().
class WebPanel : public wxPanel
{
public:
WebPanel(wxWindow* parent, const char* bridge_script);
protected:
wxWebView* browser() const { return m_browser; }
// Delivers an already serialised JSON value to the page's window.orca.onMessage handlers,
// waiting briefly for the bridge while the page is still loading. Main thread only.
void post_to_page(const std::string& json);
// The plugin HTML to show once the bootstrap page has loaded, and again when WebKit reloads it;
// std::nullopt leaves it blank.
virtual std::optional<std::string> page_html() = 0;
// A window.orca message from the page; false for a kind this host does not handle (logged).
virtual bool on_page_message(const std::string& kind, const nlohmann::json& data) = 0;
private:
void on_load_event(wxWebViewEvent& event);
void on_navigated(wxWebViewEvent& event);
void on_script_message(wxWebViewEvent& event);
void on_webview_recreated(wxCommandEvent& event);
void apply_theme();
void load_page_html();
wxWebView* m_browser{nullptr};
bool m_content_loaded{false};
bool m_own_page_load{false}; // a SetPage of the plugin HTML is in flight
bool m_content_navigated{false}; // a navigation to the base URL has committed
};
}} // namespace Slic3r::GUI
+69
View File
@@ -0,0 +1,69 @@
#include "WebHosting.hpp"
#include "slic3r/GUI/GUI.hpp"
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <wx/uri.h>
namespace Slic3r { namespace GUI { namespace web_hosting {
namespace {
// Injected into the top-level page at document start (before the plugin's own
// scripts). Defines window.orca as the only host surface the page may use. It
// references window.wx lazily (at call time) so it never races the backend's
// deferred registration of the "wx" message handler. Guarded against
// double-injection so it is harmless if also prepended.
constexpr char ORCA_BRIDGE_JS[] = R"JS(
(function () {
if (window.top !== window.self) return;
if (window.orca) return;
var handlers = [];
function send(kind, data) {
try {
window.wx.postMessage(JSON.stringify({
channel: 'orca', kind: kind, data: (data === undefined ? null : data)
}));
} catch (e) { /* bridge not ready yet */ }
}
window.orca = {
postMessage: function (d) { send('message', d); },
submit: function (d) { send('submit', d); },
close: function () { send('close'); },
onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); }
};
window.__orcaDispatch = function (payload) {
var data = payload ? payload.data : null;
for (var i = 0; i < handlers.length; i++) {
try { handlers[i](data); } catch (e) {}
}
};
})();
)JS";
} // namespace
wxString bootstrap_url()
{
return wxString("file://") + from_u8((boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE).make_preferred().string());
}
wxString content_base_url()
{
const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
return wxString("file://") + from_u8(dir) + "/";
}
bool is_content_url(const wxString& url)
{
// The web view reports the URL it parsed, which escapes anything the resources path holds
// (a space, a non-ASCII character), while content_base_url() is the raw path.
return wxURI::Unescape(url.BeforeFirst('#')) == content_base_url();
}
const char* orca_bridge_script() { return ORCA_BRIDGE_JS; }
}}} // namespace Slic3r::GUI::web_hosting
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <wx/string.h>
namespace Slic3r { namespace GUI { namespace web_hosting {
// Shared by the hosts that show plugin HTML: WebDialog and WebPanel.
// The bundled blank page a plugin web view loads before the plugin HTML is swapped in.
constexpr const char* BOOTSTRAP_PAGE = "web/dialog/WebDialog/blank.html";
// The file:// URL of BOOTSTRAP_PAGE.
wxString bootstrap_url();
// The file:// base URL plugin HTML is loaded against, so relative URLs resolve to bundled resources.
wxString content_base_url();
// Whether `url` is the plugin HTML's base URL, ignoring any fragment. WebKit reports it for the
// injected page, a reload and a failed navigation alike, so a match alone is not a new document.
bool is_content_url(const wxString& url);
// The window.orca bridge of plugin windows and docked panels. Pages tabs ship their own.
const char* orca_bridge_script();
}}} // namespace Slic3r::GUI::web_hosting
+3 -1
View File
@@ -75,6 +75,8 @@ if(document.documentElement)
} // namespace } // namespace
std::string WebViewHostDialog::theme_apply_script() { return host_theme_apply_js(); }
// Document-start user script: injects the contract <style>, stamps data-orca-theme before // Document-start user script: injects the contract <style>, stamps data-orca-theme before
// first paint, and raises a JS flag so the legacy globalapi.js dark.css poll stands down for // first paint, and raises a JS flag so the legacy globalapi.js dark.css poll stands down for
// host-themed pages. The WebView2 timing guard lives in document_start_injector(). // host-themed pages. The WebView2 timing guard lives in document_start_injector().
@@ -87,7 +89,7 @@ std::string WebViewHostDialog::theme_user_script()
"if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);"); "if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);");
} }
std::string WebViewHostDialog::plugin_defaults_user_script() std::string WebViewHostDialog::element_defaults_user_script()
{ {
std::string css; std::string css;
css += "<style id=\"orca-plugin-defaults\">"; css += "<style id=\"orca-plugin-defaults\">";
+4 -2
View File
@@ -49,9 +49,11 @@ public:
const std::string& prelude = {}, const std::string& prelude = {},
const std::string& on_inject = {}); const std::string& on_inject = {});
// Shared by modeless Pages tabs and PluginWebDialog. // Shared by WebPanel hosts and WebDialog.
static std::string theme_user_script(); static std::string theme_user_script();
static std::string plugin_defaults_user_script(); static std::string element_defaults_user_script();
// Re-themes an already-loaded page in place, for web views hosted outside a dialog.
static std::string theme_apply_script();
protected: protected:
wxWebView* browser() const { return m_browser; } wxWebView* browser() const { return m_browser; }
+5 -3
View File
@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <sstream> #include <sstream>
#include <system_error>
#include <exception> #include <exception>
#include <boost/format.hpp> #include <boost/format.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
@@ -580,9 +581,10 @@ bool C3DPrinterOS::save_api_session(const std::string &session, const std::strin
j.put("session", session); j.put("session", session);
j.put("email", email); j.put("email", email);
try { try {
auto temp_path = m_api_session_file_path + ".tmp"; std::ostringstream json;
pt::write_json(temp_path, j); pt::write_json(json, j);
boost::filesystem::rename(temp_path, m_api_session_file_path); if (const std::error_code ec = write_file_atomically(m_api_session_file_path, json.str()))
throw std::system_error(ec);
} catch (const std::exception &err) { } catch (const std::exception &err) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": failed to write json to file. Path = " BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": failed to write json to file. Path = "
<< m_api_session_file_path << m_api_session_file_path
+5 -24
View File
@@ -1475,15 +1475,8 @@ void OrcaCloudServiceAgent::save_sync_state()
if (sync_state_path.empty()) if (sync_state_path.empty())
return; return;
try { if (const std::error_code ec = write_file_atomically(sync_state_path, std::to_string(sync_state.last_sync_timestamp)))
std::string tmp_path = sync_state_path + ".tmp"; BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to save the sync state: " << ec.message();
std::ofstream ofs(tmp_path, std::ios::out | std::ios::trunc);
if (ofs.good()) {
ofs << std::to_string(sync_state.last_sync_timestamp);
ofs.close();
boost::filesystem::rename(tmp_path, sync_state_path);
}
} catch (...) {}
} }
void OrcaCloudServiceAgent::clear_sync_state() void OrcaCloudServiceAgent::clear_sync_state()
@@ -1572,22 +1565,10 @@ void OrcaCloudServiceAgent::persist_user_secret(const std::string& secret)
wxFileName::Mkdir(path.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL); wxFileName::Mkdir(path.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL);
} }
const std::string tmp_path = secret_fallback_path + ".tmp"; if (const std::error_code ec = write_file_atomically(secret_fallback_path, signed_payload, /*binary=*/true))
std::ofstream ofs(tmp_path, std::ios::out | std::ios::trunc | std::ios::binary); BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot write user secret file " << secret_fallback_path << ": " << ec.message();
if (ofs.good()) { else
ofs << signed_payload;
ofs.flush();
ofs.close();
if (wxRenameFile(wxString::FromUTF8(tmp_path.c_str()), wxString::FromUTF8(secret_fallback_path.c_str()), true)) {
stored = true; stored = true;
} else {
wxRemoveFile(wxString::FromUTF8(tmp_path.c_str()));
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to atomically replace user secret file";
}
} else {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot open user secret file for write - " << secret_fallback_path;
}
} else { } else {
// Use wxSecretStore only // Use wxSecretStore only
wxSecretStore store = wxSecretStore::GetDefault(); wxSecretStore store = wxSecretStore::GetDefault();
+4 -15
View File
@@ -249,21 +249,10 @@ bool PluginConfig::save()
return false; return false;
} }
// Write to a PID-suffixed file and rename it into place, so a crash mid-write cannot truncate an // Written beside the target and moved into place, so a crash mid-write cannot truncate an
// existing config. Same approach as AppConfig::save(). // existing config.
const std::string path_pid = (boost::format("%1%.%2%") % path % get_current_pid()).str(); if (const std::error_code ec = write_file_atomically(path, root.dump(1, '\t') + "\n")) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to write " << path << ": " << ec.message() << "; keeping the existing config";
boost::nowide::ofstream file;
file.open(path_pid, std::ios::out | std::ios::trunc);
file << root.dump(1, '\t') << std::endl;
file.close();
if (file.fail()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to write " << path_pid << "; keeping the existing config";
return false;
}
if (const std::error_code rename_ec = rename_file(path_pid, path)) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to move " << path_pid << " onto " << path << ": " << rename_ec.message();
return false; return false;
} }
+130 -26
View File
@@ -7,8 +7,10 @@
#include <slic3r/GUI/GUI_App.hpp> #include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp> #include <slic3r/GUI/MainFrame.hpp>
#include <slic3r/GUI/MsgDialog.hpp> #include <slic3r/GUI/MsgDialog.hpp>
#include <slic3r/GUI/Plater.hpp>
#include <slic3r/GUI/DockPanel.hpp>
#include <slic3r/GUI/PluginProgressDialog.hpp> #include <slic3r/GUI/PluginProgressDialog.hpp>
#include <slic3r/GUI/PluginWebDialog.hpp> #include <slic3r/GUI/WebDialog.hpp>
#include <slic3r/GUI/NotificationManager.hpp> #include <slic3r/GUI/NotificationManager.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -20,6 +22,7 @@
#include <wx/defs.h> #include <wx/defs.h>
#include <wx/window.h> #include <wx/window.h>
#include <algorithm>
#include <atomic> #include <atomic>
#include <cstdint> #include <cstdint>
#include <future> #include <future>
@@ -73,7 +76,7 @@ CallablePtr make_holder(py::object obj)
// Adapt a Python callable to a GUI message handler that acquires the GIL and // Adapt a Python callable to a GUI message handler that acquires the GIL and
// swallows/logs exceptions (a raising handler must not escape into wx events). // swallows/logs exceptions (a raising handler must not escape into wx events).
GUI::PluginWebDialog::MessageHandler make_message_adapter(py::object on_message) GUI::WebDialog::MessageHandler make_message_adapter(py::object on_message)
{ {
CallablePtr holder = make_holder(std::move(on_message)); CallablePtr holder = make_holder(std::move(on_message));
if (!holder) if (!holder)
@@ -91,7 +94,7 @@ GUI::PluginWebDialog::MessageHandler make_message_adapter(py::object on_message)
}; };
} }
GUI::PluginWebDialog::SubmitHandler make_submit_adapter(py::object on_submit) GUI::WebDialog::SubmitHandler make_submit_adapter(py::object on_submit)
{ {
CallablePtr holder = make_holder(std::move(on_submit)); CallablePtr holder = make_holder(std::move(on_submit));
if (!holder) if (!holder)
@@ -109,6 +112,25 @@ GUI::PluginWebDialog::SubmitHandler make_submit_adapter(py::object on_submit)
}; };
} }
// The plugin's on_close: fired only on a user/JS-initiated close (not forced teardown), while the
// window is alive. Empty if the plugin passed None.
std::function<void()> make_close_adapter(const CallablePtr& holder)
{
if (!holder)
return nullptr;
return [holder]() {
PythonGILState gil;
if (!gil)
return;
try {
holder->fn();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_close handler raised: " << e.what();
PyErr_Clear();
}
};
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Registry of live plugin UI resources. Keyed by an opaque id; tracks the // Registry of live plugin UI resources. Keyed by an opaque id; tracks the
// owning plugin so all of a plugin's UI can be torn down on unload. // owning plugin so all of a plugin's UI can be torn down on unload.
@@ -350,26 +372,11 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
if (!UiRegistry::instance().is_open(new_id)) if (!UiRegistry::instance().is_open(new_id))
return; return;
// Plugin's on_close: fired only on a user/JS-initiated close (not forced GUI::WebDialog::CloseHandler on_close = make_close_adapter(close_holder);
// teardown), while the dialog is alive. Empty if the plugin passed None.
GUI::PluginWebDialog::CloseHandler on_close;
if (close_holder) {
on_close = [close_holder]() {
PythonGILState gil;
if (!gil)
return;
try {
close_holder->fn();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_close handler raised: " << e.what();
PyErr_Clear();
}
};
}
// Registry cleanup: GIL-free, runs from the dialog destructor on every path. // Registry cleanup: GIL-free, runs from the dialog destructor on every path.
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); }; auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* dlg = new GUI::PluginWebDialog(ui_parent(), wxString::FromUTF8(title), html, auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html,
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter), wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE); std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE);
UiRegistry::instance().bind(new_id, dlg, plugin_key); UiRegistry::instance().bind(new_id, dlg, plugin_key);
@@ -387,14 +394,69 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
return py::cast(UiWindowHandle{new_id}); return py::cast(UiWindowHandle{new_id});
} }
// --------------------------------------------------------------------------
// orca.host.ui.create_dock_panel + UiDockPanel handle
// --------------------------------------------------------------------------
constexpr const char* DOCK_POSITIONS[] = {"left", "right", "bottom", "float"};
struct UiDockPanelHandle
{
int id{0};
};
py::object ui_create_dock_panel(const std::string& html, const std::string& title, int width, int height,
py::object on_message, py::object on_close, const std::string& dock)
{
if (std::find(std::begin(DOCK_POSITIONS), std::end(DOCK_POSITIONS), dock) == std::end(DOCK_POSITIONS))
throw std::invalid_argument("orca.host.ui.create_dock_panel dock must be \"left\", \"right\", \"bottom\" or \"float\"");
auto msg_adapter = make_message_adapter(std::move(on_message));
CallablePtr close_holder = make_holder(std::move(on_close));
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
const int w = width > 0 ? width : 320;
const int h = height > 0 ? height : 480;
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
// Deferred and pre-bound for the same reasons as create_window().
const int new_id = UiRegistry::instance().reserve_id();
UiRegistry::instance().bind(new_id, nullptr, plugin_key);
GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, dock, w, h,
msg_adapter = std::move(msg_adapter),
close_holder = std::move(close_holder)]() mutable {
if (!UiRegistry::instance().is_open(new_id))
return;
GUI::Plater* plater = GUI::wxGetApp().plater();
if (plater == nullptr || GUI::wxGetApp().is_closing()) {
UiRegistry::instance().remove(new_id);
return;
}
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
std::move(on_destroyed));
UiRegistry::instance().bind(new_id, panel, plugin_key);
plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
wxSize(w, h), [panel]() { panel->fire_close(); });
});
return py::cast(UiDockPanelHandle{new_id});
}
void handle_post(int id, py::object data) void handle_post(int id, py::object data)
{ {
if (wxTheApp == nullptr) if (wxTheApp == nullptr)
return; return;
json j = py_to_json(data); // GIL held (binding body) json j = py_to_json(data); // GIL held (binding body)
GUI::wxGetApp().CallAfter([id, j = std::move(j)]() { GUI::wxGetApp().CallAfter([id, j = std::move(j)]() {
auto* d = UiRegistry::instance().get_as<GUI::PluginWebDialog>(id); auto* window = UiRegistry::instance().get_as<wxWindow>(id);
GUI::PluginWebDialog::post_message(d, j); if (auto* panel = dynamic_cast<GUI::DockPanel*>(window))
panel->push_message(j);
else
GUI::WebDialog::post_message(dynamic_cast<GUI::WebDialog*>(window), j);
}); });
} }
@@ -403,8 +465,23 @@ void handle_close(int id)
if (wxTheApp == nullptr) if (wxTheApp == nullptr)
return; return;
GUI::wxGetApp().CallAfter([id]() { GUI::wxGetApp().CallAfter([id]() {
auto* d = UiRegistry::instance().get_as<GUI::PluginWebDialog>(id); auto* window = UiRegistry::instance().get_as<wxWindow>(id);
GUI::PluginWebDialog::request_close(d); if (auto* panel = dynamic_cast<GUI::DockPanel*>(window))
panel->request_close();
else
GUI::WebDialog::request_close(dynamic_cast<GUI::WebDialog*>(window));
});
}
void handle_show(int id, bool show)
{
if (wxTheApp == nullptr)
return;
GUI::wxGetApp().CallAfter([id, show]() {
auto* panel = UiRegistry::instance().get_as<GUI::DockPanel>(id);
GUI::Plater* plater = GUI::wxGetApp().plater();
if (panel != nullptr && plater != nullptr)
plater->show_dock_pane(panel, show);
}); });
} }
@@ -563,6 +640,31 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
"or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) " "or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) "
"is called when the page submits; offload heavy work to a thread and push results back with window.post()."); "is called when the page submits; offload heavy work to a thread and push results back with window.post().");
py::class_<UiDockPanelHandle>(ui, "UiDockPanel", "Handle to a dockable plugin HTML panel created by create_dock_panel().")
.def_property_readonly("id", [](const UiDockPanelHandle& h) { return h.id; })
.def(
"post", [](const UiDockPanelHandle& h, py::object data) { handle_post(h.id, std::move(data)); },
py::arg("data"), "Send a payload to the page (delivered to window.orca.onMessage handlers).")
.def(
"show", [](const UiDockPanelHandle& h) { handle_show(h.id, true); }, "Show the panel again after hide().")
.def(
"hide", [](const UiDockPanelHandle& h) { handle_show(h.id, false); }, "Hide the panel without closing it.")
.def(
"close", [](const UiDockPanelHandle& h) { handle_close(h.id); }, "Close the panel (fires on_close).")
.def(
"is_open", [](const UiDockPanelHandle& h) { return UiRegistry::instance().is_open(h.id); },
"Return True until the panel is closed; a hidden panel is still open.");
ui.def("create_dock_panel", &ui_create_dock_panel, py::arg("html"), py::arg("title") = "OrcaSlicer",
py::arg("width") = 320, py::arg("height") = 480, py::arg("on_message") = py::none(),
py::arg("on_close") = py::none(), py::arg("dock") = "right",
"Open an HTML panel docked beside the 3D view and return a UiDockPanel. dock is \"left\", \"right\", "
"\"bottom\" or \"float\", and width/height are in DIPs; the user can move and resize the panel, and a panel "
"opened again comes back where the window layout was last saved. The panel belongs to the Prepare and "
"Preview tabs. on_message(data) is called on the UI thread when the page posts; window.orca.close() "
"or the panel's close button closes it and calls on_close(). A post() made before the page has "
"loaded can be dropped, so have the page request its first data.");
py::class_<UiProgressHandle>(ui, "ProgressDialog", "Handle to a native progress dialog.") py::class_<UiProgressHandle>(ui, "ProgressDialog", "Handle to a native progress dialog.")
.def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100, .def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100,
py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE) py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE)
@@ -630,8 +732,10 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
// Destroy() bypasses wxEVT_CLOSE, so the plugin's on_close is not fired on // Destroy() bypasses wxEVT_CLOSE, so the plugin's on_close is not fired on
// forced teardown (intended); the resource destructor still cleans the registry. // forced teardown (intended); the resource destructor still cleans the registry.
for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) { for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) {
if (auto* dialog = dynamic_cast<GUI::PluginWebDialog*>(window)) if (auto* dialog = dynamic_cast<GUI::WebDialog*>(window))
GUI::PluginWebDialog::destroy_for_plugin(dialog); GUI::WebDialog::destroy_silently(dialog);
else if (auto* panel = dynamic_cast<GUI::DockPanel*>(window))
panel->destroy_silently();
else if (window != nullptr) else if (window != nullptr)
window->Destroy(); window->Destroy();
} }
+18 -78
View File
@@ -1,17 +1,12 @@
#include "PluginPages.hpp" #include "PluginPages.hpp"
#include "libslic3r/AppConfig.hpp" #include "libslic3r/AppConfig.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/Notebook.hpp" #include "slic3r/GUI/Notebook.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Widgets/Button.hpp" #include "slic3r/GUI/Widgets/Button.hpp"
#include "slic3r/GUI/Widgets/WebView.hpp"
#include "slic3r/GUI/Widgets/WebViewHostDialog.hpp"
#include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/GUI/wxExtensions.hpp"
#include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/Utils.hpp>
#include <algorithm> #include <algorithm>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
@@ -66,27 +61,11 @@ constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS(
} // namespace } // namespace
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability) PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
: wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) : GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS)
, m_cap(std::move(capability)) , m_cap(std::move(capability))
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this)) , m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
{ {
auto* topsizer = new wxBoxSizer(wxVERTICAL); browser()->Bind(wxEVT_WEBVIEW_NEWWINDOW, &PluginPage::on_new_window, this);
SetSizer(topsizer);
m_browser = WebView::CreateWebView(this, bootstrap_url());
if (m_browser == nullptr) {
wxLogError("Could not initialize plugin page web view");
return;
}
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
m_browser->Bind(wxEVT_WEBVIEW_LOADED, &PluginPage::on_bootstrap_event, this);
m_browser->Bind(wxEVT_WEBVIEW_ERROR, &PluginPage::on_bootstrap_event, this);
m_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, &PluginPage::on_new_window, this);
m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PluginPage::on_script_message, this);
m_browser->AddUserScript(wxString::FromUTF8(GUI::WebViewHostDialog::theme_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(GUI::WebViewHostDialog::plugin_defaults_user_script()));
m_browser->AddUserScript(PLUGIN_PAGE_BRIDGE_JS);
const std::shared_ptr<std::atomic<PluginPage*>> lifetime = m_lifetime; const std::shared_ptr<std::atomic<PluginPage*>> lifetime = m_lifetime;
m_cap->set_message_sender([lifetime](const std::string& message) { m_cap->set_message_sender([lifetime](const std::string& message) {
@@ -117,89 +96,54 @@ void PluginPage::detach_capability()
m_cap.reset(); m_cap.reset();
} }
wxString PluginPage::web_base_url() const std::optional<std::string> PluginPage::page_html()
{ {
const auto path = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string(); if (m_cap == nullptr)
return wxString("file://") + GUI::from_u8(path) + "/"; return std::nullopt;
}
wxString PluginPage::bootstrap_url() const
{
const auto path = (boost::filesystem::path(resources_dir()) / "web/dialog/PluginWebDialog/blank.html").make_preferred().string();
return wxString("file://") + GUI::from_u8(path);
}
void PluginPage::on_bootstrap_event(wxWebViewEvent& event)
{
load_plugin_content();
event.Skip();
}
void PluginPage::load_plugin_content()
{
if (m_content_loaded || m_browser == nullptr || m_cap == nullptr)
return;
m_content_loaded = true;
try { try {
m_browser->SetPage(wxString::FromUTF8(m_cap->get_ui()), web_base_url()); return m_cap->get_ui();
} catch (const std::exception& error) { } catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "': " << error.what(); BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "': " << error.what();
detach_capability();
} catch (...) { } catch (...) {
BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "'"; BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "'";
detach_capability();
} }
detach_capability();
return std::nullopt;
} }
void PluginPage::on_new_window(wxWebViewEvent& event) void PluginPage::on_new_window(wxWebViewEvent& event)
{ {
const wxString url = event.GetURL(); const wxString url = event.GetURL();
if (!url.empty() && m_browser != nullptr) if (!url.empty())
m_browser->LoadURL(url); browser()->LoadURL(url);
event.Veto(); event.Veto();
} }
void PluginPage::on_script_message(wxWebViewEvent& event) bool PluginPage::on_page_message(const std::string& kind, const nlohmann::json& data)
{ {
if (kind != "message")
return false;
if (!m_cap) if (!m_cap)
return; return true;
const wxString payload = event.GetString();
nlohmann::json root = nlohmann::json::parse(payload.utf8_string(), nullptr, false);
if (root.is_discarded() || root.value("channel", std::string()) != "orca" ||
root.value("kind", std::string()) != "message")
return;
const auto data = root.find("data");
try { try {
m_cap->on_message(data == root.end() m_cap->on_message(data.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
? "null"
: data->dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
} catch (const std::exception& error) { } catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "': " << error.what(); BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "': " << error.what();
} catch (...) { } catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "'"; BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "'";
} }
return true;
} }
void PluginPage::push_message(const std::string& message) void PluginPage::push_message(const std::string& message)
{ {
if (m_browser == nullptr)
return;
// PagesPluginCapability::post_message() already dumps JSON, so accept it as-is; only a // PagesPluginCapability::post_message() already dumps JSON, so accept it as-is; only a
// non-JSON payload needs wrapping as a string literal. // non-JSON payload needs wrapping as a string literal.
const std::string payload = nlohmann::json::accept(message) post_to_page(nlohmann::json::accept(message)
? message ? message
: nlohmann::json(message).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); : nlohmann::json(message).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
WebView::RunScript(m_browser, wxString::Format(
"(function dispatch(payload, attempts) {\n"
" if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n"
" if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n"
"})({data: %s}, 0);",
wxString::FromUTF8(payload)));
} }
PluginPages::~PluginPages() PluginPages::~PluginPages()
@@ -268,10 +212,6 @@ bool PluginPages::create_page(const PluginCapabilityId& id)
} }
auto* page = new PluginPage(m_parent, std::move(capability)); auto* page = new PluginPage(m_parent, std::move(capability));
if (!page->is_valid()) {
page->Destroy();
return false;
}
if (!icon.empty()) { if (!icon.empty()) {
try { try {
+7 -11
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <slic3r/GUI/WebPanel.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp> #include <slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp>
@@ -11,14 +12,13 @@
#include <vector> #include <vector>
#include <wx/bitmap.h> #include <wx/bitmap.h>
#include <wx/panel.h>
#include <wx/webview.h> #include <wx/webview.h>
class Notebook; class Notebook;
namespace Slic3r { namespace Slic3r {
class PluginPage : public wxPanel class PluginPage : public GUI::WebPanel
{ {
public: public:
PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability); PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability);
@@ -26,24 +26,20 @@ public:
PluginPage() = delete; PluginPage() = delete;
bool is_valid() const { return m_browser != nullptr && m_cap != nullptr; }
void detach_capability(); void detach_capability();
void on_bootstrap_event(wxWebViewEvent& event);
void on_new_window(wxWebViewEvent& event);
void on_script_message(wxWebViewEvent& event);
void push_message(const std::string& message); void push_message(const std::string& message);
void set_icon(const wxBitmap& icon) { m_icon = icon; } void set_icon(const wxBitmap& icon) { m_icon = icon; }
const wxBitmap& icon() const { return m_icon; } const wxBitmap& icon() const { return m_icon; }
protected:
std::optional<std::string> page_html() override;
bool on_page_message(const std::string& kind, const nlohmann::json& data) override;
private: private:
void load_plugin_content(); void on_new_window(wxWebViewEvent& event);
wxString bootstrap_url() const;
wxString web_base_url() const;
wxWebView* m_browser{nullptr};
std::shared_ptr<PagesPluginCapability> m_cap; std::shared_ptr<PagesPluginCapability> m_cap;
std::shared_ptr<std::atomic<PluginPage*>> m_lifetime; std::shared_ptr<std::atomic<PluginPage*>> m_lifetime;
bool m_content_loaded{false};
wxBitmap m_icon; wxBitmap m_icon;
}; };
+1
View File
@@ -49,6 +49,7 @@ add_executable(${_TEST_NAME}_tests
test_ordering_strategies.cpp test_ordering_strategies.cpp
# test_png_io.cpp # test_png_io.cpp
test_indexed_triangle_set.cpp test_indexed_triangle_set.cpp
test_instance_lock.cpp
../libnest2d/printer_parts.cpp ../libnest2d/printer_parts.cpp
) )
+204
View File
@@ -0,0 +1,204 @@
#include <catch2/catch_all.hpp>
#include <atomic>
#include <chrono>
#include <thread>
#include <boost/filesystem.hpp>
#include "libslic3r/InstanceLock.hpp"
#include "test_utils.hpp"
#ifndef _WIN32
#include <fcntl.h>
#include <sys/file.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
using namespace Slic3r;
using namespace std::chrono_literals;
// Sets a process-wide knob for one test and restores it however the test ends.
template<typename T> struct ScopedStaticValue
{
T &ref;
T saved;
ScopedStaticValue(T &ref, T value) : ref(ref), saved(ref) { ref = value; }
~ScopedStaticValue() { ref = saved; }
};
TEST_CASE("InstanceLock creates its lock file and holds it for the guard's scope", "[InstanceLock]")
{
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
{
InstanceLock lock(path);
REQUIRE(lock.locked());
REQUIRE(boost::filesystem::exists(path));
}
// Released: a fresh guard gets the lock at once instead of waiting out a timeout.
const auto started = std::chrono::steady_clock::now();
InstanceLock again(path, 5000ms);
REQUIRE(again.locked());
// Well inside the timeout it would otherwise have waited out; loose enough for a loaded runner.
REQUIRE(std::chrono::steady_clock::now() - started < 4000ms);
}
TEST_CASE("InstanceLock nests within one thread", "[InstanceLock]")
{
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
InstanceLock outer(path);
{
InstanceLock inner(path, 100ms);
REQUIRE(inner.locked());
}
// The inner guard leaving does not release the outer one.
REQUIRE(outer.locked());
}
TEST_CASE("InstanceLock is a no-op for an empty path and survives an unwritable one", "[InstanceLock]")
{
ScopedTemporaryDir dir;
InstanceLock none("");
REQUIRE_FALSE(none.locked());
// The directory does not exist, so the lock file cannot be created; the
// guard still constructs and the write it guards can go ahead.
InstanceLock unwritable((dir.path() / "missing" / "shared.lock").string(), 100ms);
REQUIRE_FALSE(unwritable.locked());
}
TEST_CASE("InstanceLock retries a lock file it could not open once the cool-down passes", "[InstanceLock]")
{
ScopedTemporaryDir dir;
const std::string path = (dir.path() / "later" / "shared.lock").string();
ScopedStaticValue cooldown(InstanceLock::cooldown, 300ms);
bool before_dir, during_cooldown, after_cooldown;
const auto started = std::chrono::steady_clock::now();
{
InstanceLock lock(path, 100ms);
before_dir = lock.locked();
}
boost::filesystem::create_directories(dir.path() / "later");
{
InstanceLock lock(path, 100ms);
during_cooldown = lock.locked();
}
const bool second_guard_inside_cooldown = std::chrono::steady_clock::now() - started < InstanceLock::cooldown;
std::this_thread::sleep_for(400ms);
{
InstanceLock lock(path, 100ms);
after_cooldown = lock.locked();
}
REQUIRE_FALSE(before_dir);
// A loaded runner may take longer than the cool-down to get here; then the
// second guard legitimately retried, so only assert when the timing held.
if (second_guard_inside_cooldown)
REQUIRE_FALSE(during_cooldown);
REQUIRE(after_cooldown);
}
TEST_CASE("InstanceLock reopens a lock file that was replaced on disk", "[InstanceLock]")
{
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
{
InstanceLock lock(path);
REQUIRE(lock.locked());
}
boost::filesystem::remove(path);
InstanceLock lock(path);
REQUIRE(lock.locked());
// Each outermost guard opens the file afresh, so the deleted path is back.
REQUIRE(boost::filesystem::exists(path));
}
TEST_CASE("InstanceLock serialises the threads of one process", "[InstanceLock]")
{
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
std::atomic<bool> holder_ready{false};
std::atomic<bool> holder_released{false};
std::thread holder([&] {
InstanceLock lock(path);
holder_ready = true;
std::this_thread::sleep_for(150ms);
holder_released = true;
});
while (! holder_ready)
std::this_thread::yield();
bool released_before_acquire = false;
{
InstanceLock lock(path);
released_before_acquire = holder_released;
}
holder.join();
REQUIRE(released_before_acquire);
}
#ifndef _WIN32
// The cross-process side of the lock is a POSIX flock, which a child process
// takes here directly; LockFileEx backs the guard on Windows, but spawning a
// child there is not worth a test.
TEST_CASE("InstanceLock yields to another process and reports it", "[InstanceLock]")
{
ScopedTemporaryFile lock_file(".lock");
const std::string path = lock_file.string();
ScopedStaticValue cooldown(InstanceLock::cooldown, 300ms);
int child_holds[2], child_may_exit[2];
REQUIRE(::pipe(child_holds) == 0);
REQUIRE(::pipe(child_may_exit) == 0);
const pid_t child = ::fork();
REQUIRE(child >= 0);
if (child == 0) {
int fd = ::open(path.c_str(), O_RDWR | O_CREAT, 0644);
char byte = ::flock(fd, LOCK_EX | LOCK_NB) == 0 ? '1' : '0';
if (::write(child_holds[1], &byte, 1) != 1 || ::read(child_may_exit[0], &byte, 1) != 1)
::_exit(1);
::_exit(0);
}
char byte = '0';
REQUIRE(::read(child_holds[0], &byte, 1) == 1);
REQUIRE(byte == '1');
bool locked_while_child_holds;
{
InstanceLock lock(path, 100ms);
locked_while_child_holds = lock.locked();
}
// The timed-out wait starts a cool-down: the next guard does not touch the file.
const auto started = std::chrono::steady_clock::now();
bool locked_during_cooldown;
{
InstanceLock lock(path, 5000ms);
locked_during_cooldown = lock.locked();
}
const auto cooldown_wait = std::chrono::steady_clock::now() - started;
REQUIRE(::write(child_may_exit[1], "x", 1) == 1);
int status = 0;
REQUIRE(::waitpid(child, &status, 0) == child);
for (int fd : {child_holds[0], child_holds[1], child_may_exit[0], child_may_exit[1]})
::close(fd);
REQUIRE_FALSE(locked_while_child_holds);
REQUIRE_FALSE(locked_during_cooldown);
REQUIRE(cooldown_wait < 4000ms);
// Once the cool-down passes, the lock the child released is taken again.
std::this_thread::sleep_for(400ms);
InstanceLock lock(path);
REQUIRE(lock.locked());
}
#endif
+109
View File
@@ -10,6 +10,8 @@
#include <cctype> #include <cctype>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <thread>
#include <system_error>
#ifndef _WIN32 #ifndef _WIN32
#include <unistd.h> // getuid #include <unistd.h> // getuid
@@ -62,6 +64,113 @@ TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[ut
#endif #endif
} }
TEST_CASE("write_file_atomically replaces the target and leaves no temporary file", "[utils]") {
ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "preset.json";
REQUIRE_FALSE(write_file_atomically(target.string(), "first"));
REQUIRE_FALSE(write_file_atomically(target.string(), "second"));
std::string content;
load_string_file(target, content);
REQUIRE(content == "second");
size_t entries = 0;
for (auto &entry : boost::filesystem::directory_iterator(dir.path())) {
(void) entry;
++entries;
}
REQUIRE(entries == 1);
}
TEST_CASE("write_file_atomically reports a missing directory and writes nothing", "[utils]") {
ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "missing" / "preset.json";
const std::error_code ec = write_file_atomically(target.string(), "x");
REQUIRE(ec == std::errc::no_such_file_or_directory);
REQUIRE_FALSE(boost::filesystem::exists(target));
}
#ifndef _WIN32
// The read-only bit on a directory stops file creation only on POSIX.
TEST_CASE("write_file_atomically writes in place when no temporary can be created beside an existing target", "[utils]") {
if (::geteuid() == 0)
SKIP("a read-only directory does not stop root");
ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "preset.json";
REQUIRE_FALSE(write_file_atomically(target.string(), "first"));
boost::filesystem::permissions(dir.path(), boost::filesystem::owner_read | boost::filesystem::owner_exe);
const std::error_code replaced = write_file_atomically(target.string(), "second");
const std::error_code created = write_file_atomically((dir.path() / "new.json").string(), "x");
// Restored before any assertion, so a failure never leaves an unremovable directory behind.
boost::filesystem::permissions(dir.path(), boost::filesystem::owner_all);
REQUIRE_FALSE(replaced);
REQUIRE(created == std::errc::permission_denied);
std::string content;
load_string_file(target, content);
REQUIRE(content == "second");
}
#endif
TEST_CASE("write_file_atomically keeps bytes intact in binary mode", "[utils]") {
ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "blob.bin";
const std::string bytes("a\r\nb\0c", 6);
REQUIRE_FALSE(write_file_atomically(target.string(), bytes, /*binary=*/true));
REQUIRE(boost::filesystem::file_size(target) == bytes.size());
}
#ifndef _WIN32
TEST_CASE("write_file_atomically writes through a symlink and keeps the target's permissions", "[utils]") {
ScopedTemporaryDir dir;
const boost::filesystem::path real = dir.path() / "real.json";
const boost::filesystem::path link = dir.path() / "link.json";
REQUIRE_FALSE(write_file_atomically(real.string(), "first"));
boost::filesystem::permissions(real, boost::filesystem::owner_read | boost::filesystem::owner_write);
boost::filesystem::create_symlink(real, link);
REQUIRE_FALSE(write_file_atomically(link.string(), "second"));
REQUIRE(boost::filesystem::is_symlink(boost::filesystem::symlink_status(link)));
std::string content;
load_string_file(real, content);
REQUIRE(content == "second");
REQUIRE_FALSE(write_file_atomically(real.string(), "third"));
const auto perms = boost::filesystem::status(real).permissions() & boost::filesystem::all_all;
REQUIRE(perms == (boost::filesystem::owner_read | boost::filesystem::owner_write));
}
#endif
TEST_CASE("write_file_atomically survives two threads writing one target", "[utils]") {
ScopedTemporaryDir dir;
const boost::filesystem::path target = dir.path() / "shared.json";
const std::string a(20000, 'a'), b(20000, 'b');
std::thread other([&] {
for (int i = 0; i < 50; ++i)
write_file_atomically(target.string(), a);
});
for (int i = 0; i < 50; ++i)
write_file_atomically(target.string(), b);
other.join();
std::string content;
load_string_file(target, content);
const bool whole = content == a || content == b;
REQUIRE(whole);
// No temporary may be left; a scanner on Windows may briefly hold the old
// file under another name, so only the temporaries are counted.
size_t temporaries = 0;
for (auto &entry : boost::filesystem::directory_iterator(dir.path()))
if (entry.path().extension() == ".tmp")
++temporaries;
REQUIRE(temporaries == 0);
}
TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[utils]") { TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[utils]") {
ScopedTemporaryFile source(".txt"); ScopedTemporaryFile source(".txt");
{ {
-18
View File
@@ -1356,24 +1356,6 @@ TEST_CASE("a header claiming more body than the file holds is rejected", "[Vendo
REQUIRE_FALSE(bundle.load_vendor_cache(cache, "Bounded", Semver(1, 0, 0))); REQUIRE_FALSE(bundle.load_vendor_cache(cache, "Bounded", Semver(1, 0, 0)));
} }
TEST_CASE("a failed write leaves the previous cache in place", "[VendorCache]")
{
TempDir tmp;
const std::string cache = (tmp.path / "Durable.opc").string();
REQUIRE(save_one_vendor(cache, one_vendor("Durable"), "Durable", "1.0.0"));
const std::string before = slurp(cache);
// A directory where the temp file wants to go: the write cannot complete,
// and must not have destroyed what was already there to find that out.
const fs::path blocker = fs::path(cache + "." + std::to_string(get_current_pid()) + ".tmp");
fs::create_directories(blocker);
REQUIRE_FALSE(save_one_vendor(cache, one_vendor("Durable"), "Durable", "2.0.0"));
CHECK(slurp(cache) == before);
fs::remove_all(blocker);
}
TEST_CASE("a cache written by another build's option ordering still loads", "[VendorCache]") TEST_CASE("a cache written by another build's option ordering still loads", "[VendorCache]")
{ {
// The regression the fingerprint used to prevent by refusing the file // The regression the fingerprint used to prevent by refusing the file
@@ -3,8 +3,12 @@
#include <libslic3r/Model.hpp> #include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp> #include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp> #include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/DockPanel.hpp>
#include <slic3r/GUI/AuiPaneLayout.hpp>
#include <slic3r/GUI/Widgets/WebHosting.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp> #include <slic3r/plugin/PythonPluginBridge.hpp>
#include "plugin_test_utils.hpp"
#include "python_test_support.hpp" #include "python_test_support.hpp"
#include <pybind11/embed.h> #include <pybind11/embed.h>
@@ -141,6 +145,8 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0); CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1); CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
CHECK(has_attr(ui, "UiWindow")); CHECK(has_attr(ui, "UiWindow"));
CHECK(has_attr(ui, "create_dock_panel"));
CHECK(has_attr(ui, "UiDockPanel"));
// With no wx application the UI calls marshal to a main thread that does not // With no wx application the UI calls marshal to a main thread that does not
// exist here; they must fail cleanly with a clear error, not crash. // exist here; they must fail cleanly with a clear error, not crash.
@@ -151,6 +157,74 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(error.matches(PyExc_RuntimeError)); CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
} }
try {
ui.attr("create_dock_panel")("<p>panel</p>");
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
// Positional arguments follow create_window(): width and height come straight after the title.
try {
ui.attr("create_dock_panel")("<p>panel</p>", "Panel", 400, 300);
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
}
// An unknown dock position is rejected before the application is needed.
try {
ui.attr("create_dock_panel")("<p>panel</p>", py::arg("dock") = "top");
FAIL("orca.host.ui.create_dock_panel accepted an unknown dock position");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}
TEST_CASE("Plugin pane names identify the plugin and title without layout delimiters", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_name;
CHECK(plugin_pane_name("dock_demo", "Scene") == "plugin:dock_demo:Scene");
CHECK(plugin_pane_name("key", "a|b;c=d\\e").find_first_of("|;=\\") == std::string::npos);
}
TEST_CASE("A pane's saved layout entry is found by pane name", "[PluginHost]")
{
using Slic3r::GUI::aui_pane_layout_entry;
const std::string sidebar = "name=sidebar;caption=;state=2099196;dir=4;layer=0;row=0;pos=0;bestw=390;besth=900";
// The caption holds an escaped '|', which must not end the entry.
const std::string plugin = "name=plugin:demo:Scene;caption=Scene \\| stats;state=2099198;dir=2;layer=0;row=1;pos=0;bestw=320;besth=480";
const std::string layout = "layout3|" + sidebar + "|" + plugin + "|dock_size(4,0,0)=392|";
CHECK(aui_pane_layout_entry(layout, "plugin:demo:Scene") == plugin);
CHECK(aui_pane_layout_entry(layout, "sidebar") == sidebar);
CHECK(aui_pane_layout_entry(layout, "plugin:demo").empty());
CHECK(aui_pane_layout_entry("", "plugin:demo:Scene").empty());
}
TEST_CASE("A reloaded plugin page is recognised by its base URL, fragment aside", "[PluginHost]")
{
using namespace Slic3r::GUI::web_hosting;
// A resources path holding a space, which the web view reports escaped.
const Slic3r::ScopedResourcesDir resources("web content check");
// The swapped-in page, then after an in-page anchor and a reload.
CHECK(is_content_url(content_base_url()));
CHECK(is_content_url(content_base_url() + "#tab2"));
wxString escaped = content_base_url();
escaped.Replace(" ", "%20");
REQUIRE(escaped != content_base_url());
CHECK(is_content_url(escaped));
CHECK(is_content_url(escaped + "#tab2"));
// A page the plugin linked to keeps its own URL and must be left alone.
CHECK_FALSE(is_content_url(content_base_url() + "guide.html"));
CHECK_FALSE(is_content_url("https://example.com/"));
CHECK_FALSE(is_content_url(""));
} }
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]") TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")