mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 06:01:16 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c791532c6 | ||
|
|
4c2ae6d202 | ||
|
|
ac022b56ad | ||
|
|
d3699dd9f6 | ||
|
|
a315a96c5c | ||
|
|
24cf1c6bcc | ||
|
|
d16ca97380 | ||
|
|
da4d766d46 | ||
|
|
06665d0558 | ||
|
|
0f59cd5d05 | ||
|
|
c1fb801ff3 | ||
|
|
1ee0b730a1 | ||
|
|
72ba7a2b35 | ||
|
|
8160f64cae |
No files matched your search
@@ -574,7 +574,8 @@ DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the
|
|||||||
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
|
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
|
||||||
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
|
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
|
||||||
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
|
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
|
||||||
DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
|
DMABUF renderer. On a hypervisor guest (`/proc/cpuinfo` `hypervisor` flag) and WebKitGTK >= 2.44 it sets
|
||||||
|
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` alone, since virtual GPUs cannot back the hardware path either. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
|
||||||
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
|
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
|
||||||
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
|
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
|
||||||
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
|
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
|
||||||
|
|||||||
@@ -568,13 +568,18 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
|
|||||||
## WebKitGTK on Linux sessions
|
## WebKitGTK on Linux sessions
|
||||||
|
|
||||||
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
|
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
|
||||||
sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`:
|
sets the DMABUF workarounds and then branches on `GDK_BACKEND`:
|
||||||
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
|
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
|
||||||
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
|
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
|
||||||
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
|
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
|
||||||
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
|
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
|
||||||
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
|
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
|
||||||
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
|
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
|
||||||
|
- **Hypervisor guests**, same position: when `is_hypervisor_guest()` (the `hypervisor` flag in
|
||||||
|
`/proc/cpuinfo`) and WebKitGTK >= 2.44, it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` non-replacing.
|
||||||
|
Virtual GPUs (VirtualBox VMSVGA, VMware, QEMU) cannot back the hardware DMABUF path either, and
|
||||||
|
FORCE_SHM alone was confirmed to fix web views in VirtualBox; the `WEBKIT_FORCE_DMABUF_RENDERER` pair
|
||||||
|
is not needed without the Debian `isNVIDIA()` patch. The flag is x86-only, so ARM guests are not covered.
|
||||||
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
|
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
|
||||||
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
|
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
|
||||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
|
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
|
||||||
@@ -590,9 +595,9 @@ Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_
|
|||||||
(see `references/platforms.md`), not these environment variables.
|
(see `references/platforms.md`), not these environment variables.
|
||||||
|
|
||||||
**Pitfall.**
|
**Pitfall.**
|
||||||
- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
|
- **Rule:** On NVIDIA and hypervisor guests reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
|
||||||
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware
|
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver and virtual GPUs cannot back WebKit's
|
||||||
DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
|
hardware DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
|
||||||
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
|
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
|
||||||
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
|
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
|
||||||
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
|
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
|
||||||
|
|||||||
@@ -232,6 +232,31 @@ jobs:
|
|||||||
mkdir -p ${{ runner.temp }}/profile-check-results
|
mkdir -p ${{ runner.temp }}/profile-check-results
|
||||||
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
||||||
|
|
||||||
|
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
|
||||||
|
# one long line per setting, for check_profiles_comment.yml to post.
|
||||||
|
- name: Diff custom G-code
|
||||||
|
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
|
||||||
|
results=${{ runner.temp }}/profile-check-results
|
||||||
|
diff=${{ runner.temp }}/gcode_diff.md
|
||||||
|
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
|
||||||
|
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
|
||||||
|
if [ -s "$diff" ]; then
|
||||||
|
{
|
||||||
|
echo "<!-- profile-gcode-diff-comment -->"
|
||||||
|
cat "$diff"
|
||||||
|
echo ""
|
||||||
|
# The comment workflow finds this line as the only one starting with <sub>.
|
||||||
|
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
|
||||||
|
} > "$results/gcode_diff.md"
|
||||||
|
fi
|
||||||
|
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
|
||||||
|
touch "$results/gcode_diff_done.txt"
|
||||||
|
|
||||||
- name: Prepare comment artifact
|
- name: Prepare comment artifact
|
||||||
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GH_REPO: ${{ github.repository }}
|
GH_REPO: ${{ github.repository }}
|
||||||
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f pr_number.txt ]; then
|
if [ ! -f pr_number.txt ]; then
|
||||||
echo "No pr_number.txt in artifact, skipping."
|
echo "No pr_number.txt in artifact, skipping."
|
||||||
@@ -49,18 +50,72 @@ jobs:
|
|||||||
echo "Invalid PR number: $PR_NUMBER"
|
echo "Invalid PR number: $PR_NUMBER"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# PR code wrote the artifact, so only a run for the named PR's current head may post.
|
||||||
|
if [ -z "$HEAD_SHA" ]; then
|
||||||
|
echo "The triggering run names no head commit."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
|
||||||
|
echo "Cannot look up PR #${PR_NUMBER}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
|
||||||
|
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lists every bot comment on the PR, one JSON object per line.
|
||||||
|
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
||||||
|
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
|
||||||
|
|
||||||
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
|
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
|
||||||
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
||||||
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
|
||||||
for comment_id in $OLD_IDS; do
|
for comment_id in $OLD_IDS; do
|
||||||
echo "Deleting outdated profile-validation comment ${comment_id}"
|
echo "Deleting outdated profile-validation comment ${comment_id}"
|
||||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# Post a new comment only when validation failed (pr_comment.md present).
|
# Post a new comment only when validation failed (pr_comment.md present).
|
||||||
|
# A failed post fails the job only after the G-code diff is handled.
|
||||||
|
failed=0
|
||||||
if [ -f pr_comment.md ]; then
|
if [ -f pr_comment.md ]; then
|
||||||
gh pr comment "$PR_NUMBER" --body-file pr_comment.md
|
gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
|
||||||
else
|
else
|
||||||
echo "Validation succeeded; cleaned up prior comments without posting."
|
echo "Validation succeeded; cleaned up prior comments without posting."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
|
||||||
|
update_gcode_diff_comment() {
|
||||||
|
if [ ! -f gcode_diff_done.txt ]; then
|
||||||
|
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
|
||||||
|
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
|
||||||
|
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
|
||||||
|
rm gcode_diff.md
|
||||||
|
fi
|
||||||
|
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
|
||||||
|
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
|
||||||
|
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
|
||||||
|
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
|
||||||
|
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Edits in place when only the <sub> line differs, since edits notify nobody.
|
||||||
|
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
|
||||||
|
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
|
||||||
|
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
for comment_id in $GCODE_IDS; do
|
||||||
|
echo "Deleting outdated G-code diff comment ${comment_id}"
|
||||||
|
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||||
|
done
|
||||||
|
if [ -f gcode_diff.md ]; then
|
||||||
|
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
update_gcode_diff_comment
|
||||||
|
exit "$failed"
|
||||||
@@ -53,6 +53,13 @@ Space counts as typing. The speed dial's default is the one bare Space, and
|
|||||||
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
|
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
|
||||||
combo boxes), so it opens the dial from the canvases and the tab strip only.
|
combo boxes), so it opens the dial from the canvases and the tab strip only.
|
||||||
|
|
||||||
|
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
|
||||||
|
while a text field has the focus (a wx text entry, spin control, web view or ImGui
|
||||||
|
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
|
||||||
|
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
|
||||||
|
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
|
||||||
|
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
|
||||||
|
|
||||||
## Which event a chord matches
|
## Which event a chord matches
|
||||||
|
|
||||||
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
|
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
# Preset Plugin API — High Level Design
|
|
||||||
|
|
||||||
## Purpose and scope
|
|
||||||
|
|
||||||
The preset plugin API lets Python plugins update the presets OrcaSlicer is using,
|
|
||||||
so users do not have to perform every routine setting change by hand. It covers
|
|
||||||
process, printer, and filament presets. All three use one staged update lifecycle;
|
|
||||||
the way a target is selected is specific to the preset type.
|
|
||||||
|
|
||||||
The API edits Orca's managed edited preset, not a detached copy or a saved preset
|
|
||||||
behind the UI's back. It does not save to disk. Saving, discarding, and transferring
|
|
||||||
unsaved edits remain under Orca's existing preset workflow.
|
|
||||||
|
|
||||||
The API does not create or delete presets or provide off-tab editing. For filament,
|
|
||||||
choosing a different `preset_name` as the update target also selects that preset for
|
|
||||||
the requested slot, following the normal UI flow. A standalone slot-assignment or
|
|
||||||
preset-fork operation is outside this API's contract.
|
|
||||||
|
|
||||||
## Preset and slot model
|
|
||||||
|
|
||||||
Each preset collection maintains a managed edited copy for its selected preset.
|
|
||||||
Settings tabs apply changes to that copy and own the normal dirty-state, validation,
|
|
||||||
save/discard/transfer, and downstream refresh behavior. Plugin updates must use the
|
|
||||||
same path so that the settings tab, dirty indicators, and effective print
|
|
||||||
configuration observe a consistent state.
|
|
||||||
|
|
||||||
Filament selection adds a slot-to-preset mapping. `PresetBundle::filaments` is the
|
|
||||||
catalog, while `PresetBundle::filament_presets` maps each slot to a preset name.
|
|
||||||
Several slots may refer to the same preset. A filament update edits that shared
|
|
||||||
preset, so all slots referring to it are affected. Slot numbers exposed by the API
|
|
||||||
are 1-based, matching the UI.
|
|
||||||
|
|
||||||
## API shape
|
|
||||||
|
|
||||||
Each preset API exposes `begin_update()`, which selects and binds one target, and
|
|
||||||
returns a transaction. The transaction stages setting changes using `set()` and
|
|
||||||
applies them only on explicit `commit()`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Filament is targeted by its 1-based slot. Omitting preset_name keeps the
|
|
||||||
# preset currently assigned to the slot; providing it selects that preset for
|
|
||||||
# the slot before staging updates.
|
|
||||||
tx = orca.host.preset.filament.begin_update(
|
|
||||||
slot_number=2,
|
|
||||||
preset_name="Generic PLA"
|
|
||||||
)
|
|
||||||
tx.set("filament_flow_ratio", "0.98")
|
|
||||||
tx.set("filament_retract_length", "0.8")
|
|
||||||
result = tx.commit()
|
|
||||||
|
|
||||||
# Process and printer default to their active presets. A caller can request a
|
|
||||||
# different preset that is selectable in the corresponding UI dropdown.
|
|
||||||
tx = orca.host.preset.process.begin_update(
|
|
||||||
preset_name="0.20mm Standard"
|
|
||||||
)
|
|
||||||
tx.set("layer_height", "0.2")
|
|
||||||
tx.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
The example is the API contract, and the Python bridge provides it. `begin_update()`
|
|
||||||
is synchronous: it blocks the background Script worker until the target selection and
|
|
||||||
any unsaved-edit dialog settle, then returns the bound transaction. Orca's UI thread
|
|
||||||
stays responsive because the Script capability runs on its own worker. `set()` and
|
|
||||||
`commit()` run synchronously on that same worker. The Script worker and its optional
|
|
||||||
coroutine loop are described in
|
|
||||||
[python-plugin-async-bridge.md](python-plugin-async-bridge.md).
|
|
||||||
|
|
||||||
### Target resolution
|
|
||||||
|
|
||||||
- **Filament:** `slot_number` identifies the slot. If `preset_name` is omitted,
|
|
||||||
the transaction targets the preset currently assigned to that slot. If supplied,
|
|
||||||
it requests that preset for the slot and selects it through the normal UI flow;
|
|
||||||
the name must be among the presets offered by that slot's dropdown, with one
|
|
||||||
exception: the preset currently assigned to the slot stays targetable even when the
|
|
||||||
slot's dropdown hides it (for example an incompatible-but-selected preset). Selecting
|
|
||||||
a different preset changes the slot-to-preset mapping. In both cases the transaction
|
|
||||||
edits the shared preset, not an isolated slot override.
|
|
||||||
- **Process and printer:** omitting `preset_name` targets the currently active
|
|
||||||
preset. Supplying `preset_name` requests the preset by the name shown in its
|
|
||||||
dropdown. It must be one of the choices the dropdown currently offers, rather
|
|
||||||
than merely any preset present in the collection. This is particularly important
|
|
||||||
for process presets, whose selectable choices can depend on the current context.
|
|
||||||
- A transaction targets exactly one preset. Multi-preset transactions are not
|
|
||||||
supported: applying them would require switching among targets while the
|
|
||||||
application has only one managed edited copy per collection.
|
|
||||||
|
|
||||||
Targets are resolved through Orca's normal preset-selection model. The API should
|
|
||||||
not accept raw preset pointers or filesystem paths as target identities. A missing,
|
|
||||||
unavailable, or ambiguous target fails explicitly instead of silently selecting a
|
|
||||||
different preset.
|
|
||||||
|
|
||||||
## Selection and user interaction
|
|
||||||
|
|
||||||
`begin_update()` resolves its target through shared binding helpers
|
|
||||||
(`bind_preset_target` for process/printer, `bind_filament_target` for filament) and
|
|
||||||
performs the selection only when the requested target differs from the active target;
|
|
||||||
otherwise it returns a transaction without switching. The helpers use Orca's normal
|
|
||||||
preset selection flow, `Tab::select_preset()`, including updating a filament slot
|
|
||||||
assignment when requested. If the normal selection flow requires user input, it opens
|
|
||||||
the relevant existing dialog(s), including unsaved-edit choices such as save, discard,
|
|
||||||
or transfer. The call remains blocked on the Script worker until those interactions
|
|
||||||
finish; it returns the bound transaction, or raises on a cancellation, without
|
|
||||||
applying staged settings. A canceled selection does not change the requested target or
|
|
||||||
filament slot assignment, but it does not roll back decisions the user already accepted
|
|
||||||
in an earlier dialog. The helpers then confirm that the requested preset actually
|
|
||||||
became the edited preset; a successful selection request alone is not sufficient. There
|
|
||||||
is no separate `switch_preset` plugin API.
|
|
||||||
|
|
||||||
If the requested target is already active, `begin_update()` returns without a
|
|
||||||
selection prompt, apart from the UI-thread dispatch needed to bind the transaction.
|
|
||||||
If a different preset is requested, Orca selects it through the normal UI flow. For
|
|
||||||
filament, this also updates the requested slot's assignment when `preset_name`
|
|
||||||
differs from its current assignment. When the current preset has unsaved changes,
|
|
||||||
the Script worker stays blocked until the user chooses among Orca's existing options,
|
|
||||||
such as transfer, discard, or cancel.
|
|
||||||
|
|
||||||
The API never suppresses that warning, chooses an option for the user, or switches
|
|
||||||
away from dirty edits silently. Cancel aborts the operation before any staged target
|
|
||||||
changes are applied. Process and printer updates follow this rule too when the
|
|
||||||
plugin requests a different `preset_name`; they normally return immediately when
|
|
||||||
the active preset is the target.
|
|
||||||
|
|
||||||
## Staging and commit semantics
|
|
||||||
|
|
||||||
`set(key, value)` stages a serialized config value and does not mutate the preset.
|
|
||||||
The transaction is the boundary for a related group of changes, avoiding
|
|
||||||
intermediate partially-updated states. `commit()` runs synchronously on the background
|
|
||||||
Script worker. At commit, the API validates keys and values against the target preset
|
|
||||||
type and applies the batch through `Tab::apply_preset_config_values`, the existing tab
|
|
||||||
and settings-group update path. Existing setting-specific validation, correction, and
|
|
||||||
callbacks remain authoritative; plugin updates do not create a parallel setter with
|
|
||||||
different behavior.
|
|
||||||
|
|
||||||
A successful commit updates dirty state and downstream configuration/Plater state
|
|
||||||
once for the batch. The edited preset remains unsaved; persistence continues through
|
|
||||||
Orca's normal save action. A validation or commit failure restores the edited preset's
|
|
||||||
config to its pre-batch state and recomputes the state derived from it (compatibility
|
|
||||||
marks, the preset selector, dependent tabs, the Plater and the printer agent), so no
|
|
||||||
staged setting is left partially applied. That guarantee is limited to the edited
|
|
||||||
preset and its config-derived state: an `on_change` callback is a GUI method, not a pure
|
|
||||||
function of the config, so the transaction does not reverse a preset or slot selection
|
|
||||||
made while resolving the target, a dialog decision the user already accepted, an undo
|
|
||||||
snapshot, a dependent preset's rewritten config, or anything already sent to a device.
|
|
||||||
Exact all-or-nothing across those external effects is not achievable while the
|
|
||||||
authoritative GUI callbacks run; the boundary is documented on
|
|
||||||
`Tab::apply_preset_config_values`. A filament slot assignment made as part of target
|
|
||||||
selection is likewise not a staged setting: after the user accepts the selection flow it
|
|
||||||
may remain changed even if a later commit fails, and the API does not switch back or
|
|
||||||
reverse the user's selection or any accepted transfer/discard decision for the previous
|
|
||||||
preset.
|
|
||||||
|
|
||||||
## Outcomes and consistency
|
|
||||||
|
|
||||||
The API distinguishes target resolution failures, invalid setting/value, user
|
|
||||||
cancellation, stale filament-slot mapping, and commit failure. A result identifies
|
|
||||||
the target preset, whether preset selection changed, and (for filament) whether the
|
|
||||||
slot assignment changed and which slots share the edited preset. A canceled
|
|
||||||
selection applies neither the requested slot assignment nor staged target changes;
|
|
||||||
a failed commit applies none of the staged target settings.
|
|
||||||
|
|
||||||
Filament slot assignments can change between `begin_update()` and `commit()`. The API
|
|
||||||
detects that and rejects a stale operation instead of silently editing the new
|
|
||||||
slot assignment. It must not hold raw preset pointers across reloads. Conflicting
|
|
||||||
duplicate writes to one setting in a transaction are rejected rather than resolved
|
|
||||||
by incidental call order.
|
|
||||||
|
|
||||||
The existing one-setting filament call,
|
|
||||||
`orca.host.preset.filament.update(slot_number, key, value)`, remains available as a
|
|
||||||
compatibility shorthand. It shares the staged API's target resolution, selection,
|
|
||||||
validation, and commit path, and it is synchronous like `begin_update()`: both block
|
|
||||||
the background Script worker while any selection dialog settles.
|
|
||||||
|
|
||||||
## Design principles
|
|
||||||
|
|
||||||
- **Reduce routine interaction, preserve agency.** Clean switches should be
|
|
||||||
seamless; unsaved user work must still receive the normal warning and decision.
|
|
||||||
- **One source of truth.** UI and plugins share the managed edited copy and setting
|
|
||||||
update path, including validation and callbacks.
|
|
||||||
- **The UI workflow is authoritative.** The API must enter through the same
|
|
||||||
selection and setting-change workflows as the UI, not reproduce their logic or
|
|
||||||
write config values directly. This keeps existing and future side effects active:
|
|
||||||
`on_change` handling for mutually exclusive, invalid, or out-of-range settings;
|
|
||||||
compatibility and dependent-preset updates; and printer-agent changes when the
|
|
||||||
selected printer changes.
|
|
||||||
- **Explicit target and explicit commit.** Plugins know which preset they edit and
|
|
||||||
when staged changes become visible.
|
|
||||||
- **No hidden persistence.** Plugin edits remain unsaved until the user saves them
|
|
||||||
through Orca.
|
|
||||||
- **Shared means shared.** Editing a filament preset through one slot affects every
|
|
||||||
slot assigned to it. Choosing another preset for a slot is explicit in
|
|
||||||
`preset_name`; slot-specific divergence would require a separate fork operation.
|
|
||||||
- **Consistent lifecycle, type-specific targeting.** Process, printer, and
|
|
||||||
filament share transaction and error semantics while respecting their existing
|
|
||||||
selection models.
|
|
||||||
|
|
||||||
## Implementation in the current codebase
|
|
||||||
|
|
||||||
The transaction API is implemented. The current plugin surface exposes `begin_update()`,
|
|
||||||
`PresetTransaction` with `set()`/`commit()`, and the per-setting `update()` compatibility
|
|
||||||
shorthand. For filament, `update_filament_slot()` resolves the preset already assigned
|
|
||||||
to a slot, selects it through `Tab::select_preset()`, and applies one setting through
|
|
||||||
`Tab::apply_preset_config_values()`; it does not change the slot assignment.
|
|
||||||
|
|
||||||
`begin_update()` and `update()` are synchronous blocking calls
|
|
||||||
(`begin_preset_update` / `begin_filament_update`). They marshal the target binding to
|
|
||||||
the UI thread and block the background Script worker until the selection settles, so
|
|
||||||
the wait never blocks the UI thread. They are valid only on that worker, never from the
|
|
||||||
UI thread or a slicing pipeline hook.
|
|
||||||
|
|
||||||
`Tab::select_preset()` is the basis for switching to process, printer, or filament
|
|
||||||
presets and for invoking the normal unsaved-edit flow. The flow is type-specific: a
|
|
||||||
process selection can trigger compatibility handling for filaments, and a printer
|
|
||||||
selection can involve technology changes and dependent preset compatibility. It may
|
|
||||||
therefore show more than one dialog. Cancellation of a later step does not necessarily
|
|
||||||
undo decisions the user already accepted in an earlier dialog. Target resolution
|
|
||||||
preserves those side effects and then verifies the resulting edited target.
|
|
||||||
|
|
||||||
Filament `preset_name` is an additional integration on top of `Tab::select_preset()`:
|
|
||||||
selecting a different name for a slot also updates the slot-to-preset mapping and its
|
|
||||||
dependent UI/configuration state, mirroring the filament branch of the Plater's normal
|
|
||||||
selection path (`PresetBundle::set_filament_preset()` plus the project/selection refresh,
|
|
||||||
the dynamic filament list, the conditional support-filament flushing recalc, the slot
|
|
||||||
combo refresh and its AMS sync badge). A mixed-color virtual slot is not targetable.
|
|
||||||
|
|
||||||
Process and printer dropdown choices are constrained by visibility and compatibility,
|
|
||||||
and compatibility may depend on the active context. Target resolution checks the tab
|
|
||||||
dropdown's actual items before the selection flow, so a target the user cannot pick
|
|
||||||
fails without raising that flow's unsaved-edit prompt; a name two entries carry is
|
|
||||||
rejected as ambiguous. A filament `preset_name` resolves against the requested slot's
|
|
||||||
own sidebar selector rather than the tab's, so that slot's compatibility filter is the
|
|
||||||
one that applies. A name the slot's dropdown does not show is rejected unless it names
|
|
||||||
that slot's current assignment (a hidden or incompatible-but-selected preset), which
|
|
||||||
stays targetable.
|
|
||||||
|
|
||||||
`commit()` applies its batch through `Tab::apply_preset_config_values`, which parses and
|
|
||||||
validates the whole batch before touching the edited preset and restores the edited
|
|
||||||
preset and its config-derived state on failure (see "Staging and commit semantics" for
|
|
||||||
the limit of that rollback). Since `begin_update()` performs target selection before the
|
|
||||||
caller stages values, invalid staged values can still follow a user-visible selection
|
|
||||||
prompt; that is a consequence of this API shape and remains explicit in its behavior.
|
|
||||||
|
|
||||||
## Integration boundary
|
|
||||||
|
|
||||||
The plugin API coordinates target resolution, preset selection, staged values, and
|
|
||||||
the settings commit path. It does not own or duplicate preset dirty-state logic,
|
|
||||||
selection prompts, setting validation, or persistence. Target changes flow through
|
|
||||||
the same UI selection path so dependent behavior—such as selecting the printer
|
|
||||||
agent—still runs. Setting changes flow through the same UI change path so `on_change`
|
|
||||||
and related validation/correction behavior still runs. The integration bridges UI
|
|
||||||
prompts to the Python operation through the blocking `begin_update()` and reuses
|
|
||||||
the normal Plater slot-selection behavior for explicit filament targets, and commits a
|
|
||||||
batch without losing existing per-setting behavior.
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
# Python plugin async bridge — High Level Design
|
|
||||||
|
|
||||||
## Purpose and scope
|
|
||||||
|
|
||||||
The embedded Python plugin runtime runs most capability calls synchronously: a C++ call site acquires
|
|
||||||
the GIL and the capability's Python method runs to completion on the calling thread. The staged preset
|
|
||||||
update (`begin_update()` in [preset-plugin-api.md](preset-plugin-api.md)) is also synchronous, but
|
|
||||||
its target selection can open unsaved-edit dialogs. It therefore blocks the Script worker, not the UI
|
|
||||||
thread, and returns only when the selection settles.
|
|
||||||
|
|
||||||
This document describes the Script capability's background execution path, including the optional
|
|
||||||
per-invocation asyncio loop for an `async def execute`, and the blocking UI marshalling that host calls
|
|
||||||
use from that worker. The bridge is deliberately narrow: only the Script capability gains a
|
|
||||||
background/async execution path. Every other capability — including the SlicingPipeline hooks — stays
|
|
||||||
synchronous on its caller's thread.
|
|
||||||
|
|
||||||
## Execution baseline
|
|
||||||
|
|
||||||
### How a capability call runs
|
|
||||||
|
|
||||||
A capability instance is a Python object owned through `Slic3r::PluginInstanceHandle`
|
|
||||||
(`src/slic3r/plugin/PythonPluginBridge.cpp`) and handed to the rest of the program as a
|
|
||||||
`std::shared_ptr<Slic3r::PluginCapabilityInterface>`. Its identity (`name()`, `type()`,
|
|
||||||
`audit_plugin_key()`) is resolved once at materialization and cached in C++
|
|
||||||
(`PluginCapabilityInterface` in `src/slic3r/plugin/PythonPluginInterface.hpp`).
|
|
||||||
|
|
||||||
Every C++→Python call crosses a trampoline method. `PyPluginCommonTrampoline` and
|
|
||||||
`PyPluginInterfaceTrampoline` (`src/slic3r/plugin/PyPluginTrampoline.hpp`), and the per-type subclass
|
|
||||||
`PyScriptPluginCapabilityTrampoline`
|
|
||||||
(`src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp`), wrap the call in the
|
|
||||||
`ORCA_PY_OVERRIDE_AUDITED` macro. For one synchronous call that macro, in order:
|
|
||||||
|
|
||||||
1. constructs a `PluginCapabilityInterface::RefCounter`, which `increment()`s the capability's
|
|
||||||
reference count for the call's duration;
|
|
||||||
2. constructs a `PythonGILState`, which first acquires a `PythonRuntimeLease` and then
|
|
||||||
`PyGILState_Ensure()`;
|
|
||||||
3. opens a `ScopedPluginAuditContext(audit_plugin_key(), name())`;
|
|
||||||
4. dispatches into Python via `PYBIND11_OVERRIDE`/`PYBIND11_OVERRIDE_PURE`;
|
|
||||||
|
|
||||||
and unwinds those guards when the call returns.
|
|
||||||
|
|
||||||
`PythonGILState` and `PythonRuntimeLease` live in `src/slic3r/plugin/PythonInterpreter.hpp`. A lease is
|
|
||||||
a `shared_lock` on `PythonInterpreter::m_runtime_mutex`; `PythonInterpreter::shutdown()` takes the
|
|
||||||
exclusive side, so a thread that holds a lease can safely touch Python objects, and interpreter
|
|
||||||
finalization cannot run underneath it. `ScopedPluginAuditContext`
|
|
||||||
(`src/slic3r/plugin/PluginAuditManager.hpp`) stores the previous thread-local plugin/capability pair
|
|
||||||
and scoped filesystem roots, sets this call's, and restores them on destruction; the audit hook then
|
|
||||||
attributes every filesystem/socket/process event to the plugin currently executing on that thread.
|
|
||||||
|
|
||||||
### The Script capability
|
|
||||||
|
|
||||||
`Slic3r::ScriptPluginCapability` (`src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp`)
|
|
||||||
declares one pure virtual method, `ExecutionResult execute()`. The async runtime invokes it through
|
|
||||||
`invoke_script_execute` (`src/slic3r/plugin/PyPluginTrampoline.hpp`), which replicates the audited
|
|
||||||
trampoline call (ref counter, audit scope, traceback-preserving rethrow) but returns a
|
|
||||||
`ScriptExecuteInvocation` instead of forcing a cast:
|
|
||||||
|
|
||||||
- a plain `def execute` runs to completion in `invoke_script_execute` and yields
|
|
||||||
`ScriptExecuteInvocation::result`;
|
|
||||||
- an `async def execute` (or any override that returns a coroutine, detected with
|
|
||||||
`inspect.iscoroutine`) yields the coroutine object in `ScriptExecuteInvocation::awaitable` without
|
|
||||||
running it, so the runtime can drive it.
|
|
||||||
|
|
||||||
`ScriptExecuteInvocation` is defined in `PyPluginTrampoline.hpp`; `py_is_coroutine` is the detection
|
|
||||||
helper.
|
|
||||||
|
|
||||||
### UI marshalling
|
|
||||||
|
|
||||||
`PluginHostUi::run_on_ui_thread` (`src/slic3r/plugin/host/PluginHostUi.cpp`) forwards to the
|
|
||||||
file-local `run_on_ui_blocking`:
|
|
||||||
|
|
||||||
- if the caller is already on the wx main thread, it runs the callable inline after a
|
|
||||||
`py::gil_scoped_release`;
|
|
||||||
- otherwise it sets up a `std::promise`/`std::future`, releases the GIL, posts the callable with
|
|
||||||
`GUI::wxGetApp().CallAfter`, and blocks on `future.get()`.
|
|
||||||
|
|
||||||
That is a blocking handoff: the caller's stack is parked on a thread while the UI thread runs the
|
|
||||||
callable. It is the correct primitive for the synchronous `update()`/`commit()` path and for
|
|
||||||
`reload_local_bundle`/`list_bundle_ids`, but it cannot be used by an operation that must suspend.
|
|
||||||
|
|
||||||
### Slicing hooks
|
|
||||||
|
|
||||||
`Print::set_slicing_pipeline_hook_fn` is installed by `src/slic3r/plugin/PluginHooks.cpp`. The hook
|
|
||||||
runs on the slicing worker thread and dispatches to `SlicingPipelinePluginCapability::execute(ctx)`
|
|
||||||
per capability, acquiring a `PythonGILState` per capability. `SlicingPipelineContext`
|
|
||||||
(`SlicingPipelinePluginCapability.hpp`) holds raw pointers into the live slicing graph
|
|
||||||
(`ctx.print`, `ctx.object`) that are valid only for the duration of `execute(ctx)`. The UI thread can
|
|
||||||
be blocked waiting on the slicing worker (`BackgroundSlicingProcess::stop()` parks it), so a
|
|
||||||
marshalled UI call from a hook can deadlock; `PluginHostSlicing.cpp`, `SlicingPipelinePluginCapability.hpp`
|
|
||||||
and `PluginHostUi.hpp` all document this.
|
|
||||||
|
|
||||||
## Script-only async execution
|
|
||||||
|
|
||||||
### Per-invocation worker and loop
|
|
||||||
|
|
||||||
`ScriptAsyncRuntime` (declared in `src/slic3r/plugin/PluginManager.hpp`, implemented in
|
|
||||||
`PluginManager.cpp`) drives every Script invocation off the caller's thread. Its `start()` creates a
|
|
||||||
`Run` and launches a detached worker thread that runs `Impl::run_invocation`. There is one worker
|
|
||||||
thread per invocation, so no invocation ever blocks the UI thread and no invocation is queued behind
|
|
||||||
another plugin's.
|
|
||||||
|
|
||||||
`run_invocation` runs detection and execution on the worker:
|
|
||||||
|
|
||||||
- it re-checks `cancel_requested` before anything (a cancellation that arrived before the run
|
|
||||||
started is honored without running the plugin);
|
|
||||||
- it calls `invoke_script_execute`; a plain `def execute` result is used directly, and an `async def`
|
|
||||||
coroutine is driven on a loop created for that invocation:
|
|
||||||
`asyncio.new_event_loop()` → `asyncio.set_event_loop(loop)` → `loop.create_task(coroutine)` →
|
|
||||||
`loop.run_until_complete(task)` → `loop.close()`, then `asyncio.set_event_loop(None)`.
|
|
||||||
|
|
||||||
The loop is per invocation, not global, and no loop outlives its invocation. The loop for the
|
|
||||||
calling thread is published in the thread-local `tl_script_loop` and exposed through
|
|
||||||
`ScriptAsyncRuntime::current_loop()` (GIL held). A host call such as `begin_update()` does not suspend
|
|
||||||
the loop: it blocks the worker thread while the UI settles, then returns a value the coroutine uses
|
|
||||||
directly.
|
|
||||||
|
|
||||||
`PluginManager::launch_script_capability` is the entry point: it resolves the capability, hands it to
|
|
||||||
`ScriptAsyncRuntime::start`, and sets `ScriptLaunchResult::launched_async`. The completion callback
|
|
||||||
runs on the GUI thread (or inline when there is no wx app), and `launch_script_capability` records a
|
|
||||||
run error through `set_plugin_error` before invoking the caller's `ScriptCompletionFn`.
|
|
||||||
`PluginManager::run_script_capability` is kept as a compatibility wrapper: it waits for the
|
|
||||||
completion callback, pumping `wxYield()` when it is itself on the GUI thread, and returns the
|
|
||||||
`ExecutionResult`.
|
|
||||||
|
|
||||||
### Capability, runtime, GIL and audit lifetime across a suspension
|
|
||||||
|
|
||||||
For the whole invocation — including an `async def` suspended at an `await` — `run_invocation` holds:
|
|
||||||
|
|
||||||
- a `PluginCapabilityInterface::RefCounter` per invocation and a `shared_ptr` to the capability in the
|
|
||||||
`Run`, so the capability and its module stay alive across awaits and unload cannot drop them;
|
|
||||||
- a `PythonRuntimeLease` (acquired before the GIL). `PythonInterpreter::shutdown()` cannot finalize
|
|
||||||
the interpreter while any invocation worker holds a lease;
|
|
||||||
- the GIL, acquired with `pybind11::gil_scoped_acquire` on the worker;
|
|
||||||
- a `ScopedPluginAuditContext(audit_plugin_key(), name())`, so filesystem/socket/process events
|
|
||||||
raised by the coroutine are attributed to the plugin.
|
|
||||||
|
|
||||||
The capability reference is released inside the GIL scope (`run->capability.reset()`), because
|
|
||||||
dropping the pybind holder from a non-Python thread would `DECREF` without the GIL. The loop is
|
|
||||||
closed and `tl_script_loop` cleared before the worker exits.
|
|
||||||
|
|
||||||
A consequence of running Script on a non-main thread: the audit permission prompt is fail-closed off
|
|
||||||
the main thread. `request_permission` in `src/slic3r/plugin/PluginAuditManager.cpp` posts the dialog
|
|
||||||
with `CallAfter` and denies the current event, persisting the grant when the user accepts, so a
|
|
||||||
Script sees a denial on the first attempt and can succeed on a later one. This matches the existing
|
|
||||||
behavior for the plugin-load worker and the slicing worker.
|
|
||||||
|
|
||||||
### Blocking UI continuation
|
|
||||||
|
|
||||||
`PluginHostUi::run_on_ui_thread` (in `src/slic3r/plugin/host/PluginHostUi.cpp`) is the primitive host
|
|
||||||
calls use from the Script worker. It forwards to `run_on_ui_blocking`:
|
|
||||||
|
|
||||||
- if the caller is already on the wx main thread, it runs the callable inline after a
|
|
||||||
`py::gil_scoped_release`;
|
|
||||||
- otherwise it sets up a `std::promise`/`std::future`, releases the GIL, posts the callable with
|
|
||||||
`GUI::wxGetApp().CallAfter`, and blocks on `future.get()`.
|
|
||||||
|
|
||||||
Because the Script capability owns a worker thread, blocking there does not freeze the UI. The same
|
|
||||||
primitive is used by `begin_update()`, `set()`/`commit()`, `reload_local_bundle()` and
|
|
||||||
`list_bundle_ids()`.
|
|
||||||
|
|
||||||
### Synchronous `begin_update()`
|
|
||||||
|
|
||||||
`begin_preset_update` and `begin_filament_update` (`src/slic3r/plugin/host/PluginHostPresets.cpp`) run
|
|
||||||
on the Script worker with the GIL held and block it:
|
|
||||||
|
|
||||||
1. the binding enters through `PluginHostUi::run_on_ui_thread`, which fails with a `RuntimeError`
|
|
||||||
before posting when there is no wx application;
|
|
||||||
2. the UI thread runs the target binding with the GIL released — `bind_preset_target` for
|
|
||||||
process/printer, `bind_filament_target` for filament — including `Tab::select_preset()` and any
|
|
||||||
unsaved-edit prompt;
|
|
||||||
3. the worker resumes with the name-based `PresetTransaction`, and nothing is staged until the
|
|
||||||
caller's later `set()`/`commit()`.
|
|
||||||
|
|
||||||
An unavailable target is rejected before selection and changes no preset. A selection the user
|
|
||||||
declines raises `RuntimeError` and creates no transaction; decisions already accepted in an earlier
|
|
||||||
dialog are not rolled back. No GUI or preset pointer outlives the call: the transaction holds only
|
|
||||||
copied category/name/slot values.
|
|
||||||
|
|
||||||
`set()` stages a serialized value without mutating the preset, and `commit()` applies the batch
|
|
||||||
through `Tab::apply_preset_config_values` on the UI thread. Both are synchronous and, per their
|
|
||||||
bindings, valid only on the background Script worker.
|
|
||||||
|
|
||||||
### Cancellation
|
|
||||||
|
|
||||||
`ScriptAsyncRuntime::cancel_plugin(plugin_key)` sets `cancel_requested` on every active run for the
|
|
||||||
plugin and, when a loop is live (`has_loop`), cancels the task with
|
|
||||||
`loop.call_soon_threadsafe(task.cancel)` while holding the GIL. A synchronous `def execute`
|
|
||||||
invocation has no loop, so only the flag is set (checked before the run starts).
|
|
||||||
|
|
||||||
A task cancellation cannot interrupt a blocking `begin_update()` native call: the worker is inside
|
|
||||||
that call, so the loop does not run the scheduled `task.cancel()` until the selection settles and the
|
|
||||||
call returns. The transaction is therefore delivered, and any accepted UI selection stands; the
|
|
||||||
cancellation is observed at the coroutine's next suspension point. A user-declined selection is an
|
|
||||||
ordinary `RuntimeError`, not `asyncio.CancelledError`.
|
|
||||||
|
|
||||||
A plugin's own `asyncio.CancelledError` is treated as a cancellation only when `cancel_requested` was
|
|
||||||
set; otherwise it stays a genuine error (see `run_invocation`), so a plugin that cancels its own task
|
|
||||||
does not masquerade as an unload cancellation.
|
|
||||||
|
|
||||||
### Unload and shutdown drain
|
|
||||||
|
|
||||||
Unloading or shutting down must guarantee that no plugin Python code runs after its module is
|
|
||||||
unloaded, and that a blocking UI selection cannot mutate presets after teardown.
|
|
||||||
|
|
||||||
- `PluginManager::cancel_and_wait_for_capabilities` calls `on_cancelled()` first (so the plugin can
|
|
||||||
observe state while the run is intact), then `ScriptAsyncRuntime::cancel_plugin`, then drains.
|
|
||||||
The drain is **unbounded while a Script invocation worker is outstanding** — its loop-exit
|
|
||||||
condition is `all_released && !invocation_active`. `has_active_run()` distinguishes a run that was
|
|
||||||
cancelled before it started from one whose worker is still live, and a worker blocked in
|
|
||||||
`begin_update()` keeps the run active until its UI callback returns. On the GUI thread the drain
|
|
||||||
pumps `wxYield()` so a modal selection can settle instead of being starved by the blocking wait.
|
|
||||||
Only the non-Script path keeps the historical bounded 2-second grace: when no invocation is active,
|
|
||||||
the drain stops waiting for capability references at the grace deadline and logs any remaining
|
|
||||||
ones.
|
|
||||||
- `PluginManager::shutdown` detaches the libslic3r hooks (`plugin_hooks::uninstall`), stops new
|
|
||||||
loads, waits for in-flight loads, unloads every plugin (draining the async runs above), then calls
|
|
||||||
`ScriptAsyncRuntime::instance().shutdown()`, which refuses new runs and waits without a deadline
|
|
||||||
for every invocation worker to finish — nothing is torn down by force. `ScriptAsyncRuntime::reset()`
|
|
||||||
re-arms the runtime so a later `initialize()` in the same process can start runs again.
|
|
||||||
|
|
||||||
Cancellation triggered by unload or shutdown is a separate, expected completion, not a plugin
|
|
||||||
failure. `run_invocation` marks a run whose `cancel_requested` was set as `completion.cancelled` and
|
|
||||||
clears any error, whatever the coroutine body did. `launch_script_capability` records a plugin error
|
|
||||||
only when `!completion.cancelled && !completion.error.empty()`; `run_script_capability` reports an
|
|
||||||
empty error for a cancelled completion; and the two GUI launchers treat `cancelled` as neither error
|
|
||||||
nor success (the speed dial stays silent, the Plugins dialog updates no status). A plugin's own
|
|
||||||
`CancelledError` is not counted as this expected termination unless `cancel_requested` is set, so a
|
|
||||||
self-cancelled plugin still records its failure.
|
|
||||||
|
|
||||||
## Supported and unsupported capabilities
|
|
||||||
|
|
||||||
| Capability | Async support | Reason |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Script | `def execute` and `async def execute` both run on the per-invocation worker | The runtime owns the worker and, for a coroutine, a per-invocation loop; nothing about a Script invocation borrows C++ state. |
|
|
||||||
| SlicingPipeline | Synchronous only | `execute(ctx)` borrows `Print`/`PrintObject` for the callback's duration, and the UI thread can be blocked on the slicing worker. `PluginHooks.cpp` rejects an async override: `cap->execute()` casts its Python result to `ExecutionResult`, and a coroutine object cannot be cast, so it surfaces as a `SlicingError` rather than being scheduled. |
|
|
||||||
| PrinterConnection, Pages, Analysis, Importer, Exporter, Visualization, ResourceTexture | Unchanged | Not part of this bridge; they keep their existing synchronous contract. |
|
|
||||||
|
|
||||||
## Deadlock constraints
|
|
||||||
|
|
||||||
- **Never block the UI thread on plugin work.** Every Script launch path goes through
|
|
||||||
`launch_script_capability`; the speed-dial `ActionRegistry` action and the Plugins dialog launcher
|
|
||||||
return immediately and report the outcome later (see Examples).
|
|
||||||
- **Never call `orca.host.ui.*` from a slicing hook.** The slicing worker runs while the UI thread
|
|
||||||
can be parked on it.
|
|
||||||
- **Marshal host UI work from the Script worker only.** `PluginHostUi::run_on_ui_thread` blocks the
|
|
||||||
calling worker while the UI thread runs the callback; calling it from the UI thread itself would
|
|
||||||
deadlock the marshalling path.
|
|
||||||
- **No pointer crosses a call boundary.** `begin_update()` returns copied category/name/slot values;
|
|
||||||
the `SlicingPipelineContext` graph is valid only within one synchronous `execute(ctx)`.
|
|
||||||
|
|
||||||
## File and symbol map
|
|
||||||
|
|
||||||
Script execution runtime:
|
|
||||||
|
|
||||||
- `src/slic3r/plugin/PluginManager.hpp` / `.cpp` — `ScriptAsyncRuntime` (`instance`, `start`,
|
|
||||||
`cancel_plugin`, `has_active_run`, `shutdown`, `reset`, `current_loop`), `ScriptCompletion`,
|
|
||||||
`ScriptCompletionFn`, `ScriptLaunchResult`, `launch_script_capability`, `run_script_capability`.
|
|
||||||
- `src/slic3r/plugin/PyPluginTrampoline.hpp` — `invoke_script_execute`, `ScriptExecuteInvocation`,
|
|
||||||
`py_is_coroutine`.
|
|
||||||
- `src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp` / `ScriptPluginCapabilityTrampoline.hpp`
|
|
||||||
— `ScriptPluginCapability::execute`, `PyScriptPluginCapabilityTrampoline`.
|
|
||||||
|
|
||||||
Lifetime and GIL primitives:
|
|
||||||
|
|
||||||
- `src/slic3r/plugin/PythonInterpreter.hpp` — `PythonInterpreter`, `PythonRuntimeLease`,
|
|
||||||
`PythonGILState`.
|
|
||||||
- `src/slic3r/plugin/PythonPluginInterface.hpp` — `PluginCapabilityInterface`, `RefCounter`,
|
|
||||||
`increment`/`decrement`/`ref_count`.
|
|
||||||
- `src/slic3r/plugin/PluginAuditManager.hpp` / `.cpp` — `ScopedPluginAuditContext`,
|
|
||||||
`request_filesystem_read_permissions`, `request_permission` (fail-closed off the main thread).
|
|
||||||
|
|
||||||
UI marshalling:
|
|
||||||
|
|
||||||
- `src/slic3r/plugin/host/PluginHostUi.hpp` / `.cpp` — `PluginHostUi::run_on_ui_thread`, the
|
|
||||||
file-local `run_on_ui_blocking`, and `close_windows_for_plugin`.
|
|
||||||
- `GUI::wxGetApp().CallAfter` — the post-to-UI-thread primitive.
|
|
||||||
|
|
||||||
Preset targets:
|
|
||||||
|
|
||||||
- `src/slic3r/plugin/host/PluginHostPresets.cpp` — `PresetEditor`, `PresetTransaction`,
|
|
||||||
`bind_preset_target`, `bind_filament_target`, `begin_preset_update`, `begin_filament_update`,
|
|
||||||
`SelectionCancelled`, `resolve_combo_preset`/`resolve_preset_in_combo`.
|
|
||||||
- `src/slic3r/GUI/Tab.hpp` / `.cpp` — `Tab::select_preset`, `Tab::apply_preset_config_values`,
|
|
||||||
`Tab::commit_preset_config_value`.
|
|
||||||
|
|
||||||
Launch paths:
|
|
||||||
|
|
||||||
- `src/slic3r/GUI/ActionRegistry.cpp` — `PluginScriptAction::run`, `notify_script_outcome`.
|
|
||||||
- `src/slic3r/GUI/PluginsDialog.cpp` — `PluginsDialog::run_script_plugin_capability`.
|
|
||||||
|
|
||||||
Slicing (synchronous only):
|
|
||||||
|
|
||||||
- `src/slic3r/plugin/PluginHooks.cpp` — `install_slicing_pipeline_hook`.
|
|
||||||
- `src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp` —
|
|
||||||
`SlicingPipelineContext`, `execute`.
|
|
||||||
|
|
||||||
Unload/shutdown:
|
|
||||||
|
|
||||||
- `src/slic3r/plugin/PluginManager.cpp` — `unload_plugin`, `unload_all_plugins`,
|
|
||||||
`cancel_and_wait_for_capabilities`, `shutdown`.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
An `async def execute` that updates a preset. `execute` runs on the background Script worker with its
|
|
||||||
own asyncio loop; `begin_update()` blocks that worker until any selection / unsaved-edit dialog is
|
|
||||||
answered, without blocking the UI thread; `set()`/`commit()` then run synchronously on the same
|
|
||||||
worker. This is the shape used by `sandboxes/orca_preset_transaction_demo.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import orca
|
|
||||||
|
|
||||||
|
|
||||||
class AutoTune(orca.script.ScriptPluginCapabilityBase):
|
|
||||||
def get_name(self):
|
|
||||||
return "Auto tune"
|
|
||||||
|
|
||||||
async def execute(self):
|
|
||||||
# begin_update() blocks this worker until the user answers any
|
|
||||||
# selection / unsaved-edit dialog. The UI thread stays responsive.
|
|
||||||
tx = orca.host.preset.process.begin_update(preset_name="0.20mm Standard")
|
|
||||||
tx.set("layer_height", "0.2")
|
|
||||||
tx.commit()
|
|
||||||
return orca.ExecutionResult.success("process preset updated")
|
|
||||||
```
|
|
||||||
|
|
||||||
The window-driven demo shows the same pattern where the plugin's own UI must hand work to the worker:
|
|
||||||
`on_message` fires on the UI thread and only enqueues onto the worker loop with
|
|
||||||
`loop.call_soon_threadsafe`; `execute` consumes that queue and calls `begin_update()` per command.
|
|
||||||
|
|
||||||
Sequence for one `begin_update(...)`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Script worker (owned, per invocation) UI thread
|
|
||||||
loop.run_until_complete(task)
|
|
||||||
begin_update() ───────────────────────► CallAfter(run_on_ui_blocking)
|
|
||||||
(blocked; not suspended) bind_*_target →
|
|
||||||
Tab::select_preset() … dialog
|
|
||||||
settles
|
|
||||||
promise set; worker resumes with PresetTransaction
|
|
||||||
set(); commit() (UI thread for the batch)
|
|
||||||
coroutine resumes at its next await
|
|
||||||
execute() returns ExecutionResult
|
|
||||||
```
|
|
||||||
|
|
||||||
A plain `def execute` needs no loop and runs to completion on the same worker; both kinds are
|
|
||||||
launched with `launch_script_capability`, so the caller (UI thread) never blocks. A launch failure is
|
|
||||||
reported inline; a started run reports its outcome through the completion callback — the speed dial
|
|
||||||
shows "started" and pushes a completion notification, and the Plugins dialog updates its status when
|
|
||||||
`finish` runs.
|
|
||||||
@@ -10657,6 +10657,9 @@ msgstr ""
|
|||||||
msgid "Send Failed"
|
msgid "Send Failed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "No available printers"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "The name length exceeds the limit."
|
msgid "The name length exceeds the limit."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Snapmaker",
|
"name": "Snapmaker",
|
||||||
"version": "02.04.00.17",
|
"version": "02.04.00.18",
|
||||||
"force_update": "0",
|
"force_update": "0",
|
||||||
"description": "Snapmaker configurations",
|
"description": "Snapmaker configurations",
|
||||||
"machine_model_list": [
|
"machine_model_list": [
|
||||||
|
|||||||
@@ -49,5 +49,8 @@
|
|||||||
],
|
],
|
||||||
"pressure_advance": [
|
"pressure_advance": [
|
||||||
"0.02"
|
"0.02"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"100"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,5 +46,8 @@
|
|||||||
],
|
],
|
||||||
"fan_min_speed": [
|
"fan_min_speed": [
|
||||||
"0"
|
"0"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"100"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"250"
|
"250"
|
||||||
],
|
],
|
||||||
"temperature_vitrification": [
|
"temperature_vitrification": [
|
||||||
"178"
|
"70"
|
||||||
],
|
],
|
||||||
"fan_max_speed": [
|
"fan_max_speed": [
|
||||||
"20"
|
"20"
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
"inherits": "fdm_filament_petg",
|
"inherits": "fdm_filament_petg",
|
||||||
"from": "system",
|
"from": "system",
|
||||||
"filament_id": "OFW8lqnb",
|
"filament_id": "OFW8lqnb",
|
||||||
"instantiation": "false"
|
"instantiation": "false",
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"70"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"250"
|
"250"
|
||||||
],
|
],
|
||||||
"temperature_vitrification": [
|
"temperature_vitrification": [
|
||||||
"178"
|
"70"
|
||||||
],
|
],
|
||||||
"fan_max_speed": [
|
"fan_max_speed": [
|
||||||
"20"
|
"20"
|
||||||
|
|||||||
@@ -34,5 +34,8 @@
|
|||||||
],
|
],
|
||||||
"nozzle_temperature": [
|
"nozzle_temperature": [
|
||||||
"220"
|
"220"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"45"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -34,5 +34,8 @@
|
|||||||
],
|
],
|
||||||
"filament_cooling_final_speed": [
|
"filament_cooling_final_speed": [
|
||||||
"60"
|
"60"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"45"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -34,5 +34,8 @@
|
|||||||
],
|
],
|
||||||
"nozzle_temperature": [
|
"nozzle_temperature": [
|
||||||
"220"
|
"220"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"45"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -40,5 +40,8 @@
|
|||||||
],
|
],
|
||||||
"nozzle_temperature": [
|
"nozzle_temperature": [
|
||||||
"220"
|
"220"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"45"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -31,5 +31,8 @@
|
|||||||
],
|
],
|
||||||
"slow_down_layer_time": [
|
"slow_down_layer_time": [
|
||||||
"8"
|
"8"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"45"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -50,5 +50,8 @@
|
|||||||
"additional_cooling_fan_speed": [
|
"additional_cooling_fan_speed": [
|
||||||
"70",
|
"70",
|
||||||
"70"
|
"70"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"30"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
"240"
|
"240"
|
||||||
],
|
],
|
||||||
"temperature_vitrification": [
|
"temperature_vitrification": [
|
||||||
"60"
|
"30"
|
||||||
],
|
],
|
||||||
"nozzle_temperature_range_low": [
|
"nozzle_temperature_range_low": [
|
||||||
"200"
|
"200"
|
||||||
|
|||||||
@@ -302,7 +302,7 @@
|
|||||||
"-1"
|
"-1"
|
||||||
],
|
],
|
||||||
"temperature_vitrification": [
|
"temperature_vitrification": [
|
||||||
"60"
|
"30"
|
||||||
],
|
],
|
||||||
"textured_cool_plate_temp": [
|
"textured_cool_plate_temp": [
|
||||||
"40"
|
"40"
|
||||||
|
|||||||
@@ -29,5 +29,8 @@
|
|||||||
"additional_cooling_fan_speed": [
|
"additional_cooling_fan_speed": [
|
||||||
"70",
|
"70",
|
||||||
"70"
|
"70"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"30"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -49,5 +49,8 @@
|
|||||||
"additional_cooling_fan_speed": [
|
"additional_cooling_fan_speed": [
|
||||||
"70",
|
"70",
|
||||||
"70"
|
"70"
|
||||||
|
],
|
||||||
|
"temperature_vitrification": [
|
||||||
|
"30"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
"20",
|
"20",
|
||||||
"12"
|
"12"
|
||||||
],
|
],
|
||||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||||
"machine_tool_change_time": "5",
|
"machine_tool_change_time": "5",
|
||||||
"max_layer_height": [
|
"max_layer_height": [
|
||||||
"0.14",
|
"0.14",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
"20",
|
"20",
|
||||||
"12"
|
"12"
|
||||||
],
|
],
|
||||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||||
"machine_tool_change_time": "5",
|
"machine_tool_change_time": "5",
|
||||||
"max_layer_height": [
|
"max_layer_height": [
|
||||||
"0.32",
|
"0.32",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
"20",
|
"20",
|
||||||
"12"
|
"12"
|
||||||
],
|
],
|
||||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||||
"machine_tool_change_time": "5",
|
"machine_tool_change_time": "5",
|
||||||
"max_layer_height": [
|
"max_layer_height": [
|
||||||
"0.42",
|
"0.42",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
"20",
|
"20",
|
||||||
"12"
|
"12"
|
||||||
],
|
],
|
||||||
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0",
|
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
|
||||||
"machine_tool_change_time": "5",
|
"machine_tool_change_time": "5",
|
||||||
"max_layer_height": [
|
"max_layer_height": [
|
||||||
"0.56",
|
"0.56",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
#
|
||||||
|
# [tool.orcaslicer.plugin]
|
||||||
|
# name = "Network Permission Probe"
|
||||||
|
# description = "Tests host-based network permission prompts and matching"
|
||||||
|
# author = "OrcaSlicer"
|
||||||
|
# version = "0.1.0"
|
||||||
|
# ///
|
||||||
|
"""Local probe for the orca.request_permissions(network=...) API.
|
||||||
|
|
||||||
|
Install this file as a local plugin, approve https://example.com, then run the
|
||||||
|
script capability more than once. Each run changes the query string; the saved
|
||||||
|
scheme/host grant should prevent another prompt for that same host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import orca
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkPermissionProbe(orca.script.ScriptPluginCapabilityBase):
|
||||||
|
def get_name(self):
|
||||||
|
return "Network Permission Probe"
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
# User-triggered GET to a reserved example domain; varying the query tests
|
||||||
|
# that permission matching is not tied to the complete URL.
|
||||||
|
url = f"https://example.com/?permission_probe={time.time_ns()}"
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as response:
|
||||||
|
return orca.ExecutionResult.success(f"Network permission probe: HTTP {response.status}")
|
||||||
|
|
||||||
|
|
||||||
|
@orca.plugin
|
||||||
|
class NetworkPermissionProbePackage(orca.base):
|
||||||
|
def register_capabilities(self):
|
||||||
|
orca.request_permissions(network=["https://example.com"])
|
||||||
|
orca.register_capability(NetworkPermissionProbe)
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# /// script
|
|
||||||
# requires-python = ">=3.12"
|
|
||||||
#
|
|
||||||
# [tool.orcaslicer.plugin]
|
|
||||||
# id = "preset-transaction-demo"
|
|
||||||
# name = "Preset Transaction Demo"
|
|
||||||
# description = "Stages several settings on one preset with begin_update()/set()/commit() (demo)."
|
|
||||||
# author = "OrcaSlicer contributors"
|
|
||||||
# version = "0.2.0"
|
|
||||||
# type = "script"
|
|
||||||
# ///
|
|
||||||
"""Preset transaction demo: change several settings with one commit.
|
|
||||||
|
|
||||||
Enable the plugin, then run "Preset Transaction Demo" from the Plugins dialog.
|
|
||||||
Edit CATEGORY, PRESET_NAME, FILAMENT_SLOT and UPDATES below to try other presets
|
|
||||||
and settings.
|
|
||||||
|
|
||||||
``begin_update()`` resolves the target through Orca's normal preset-selection
|
|
||||||
flow, so it may open Orca's usual "unsaved changes" dialog. The call is
|
|
||||||
synchronous: the background Script worker waits until the user answers, while
|
|
||||||
the UI thread stays responsive. ``set()`` only stages values; ``commit()``
|
|
||||||
validates and applies the whole batch. The edited preset stays unsaved (dirty)
|
|
||||||
until the user saves it through the normal preset workflow; nothing here writes
|
|
||||||
to disk.
|
|
||||||
|
|
||||||
Values are Orca's serialized config strings: numbers plain, percentages with
|
|
||||||
"%", booleans "1"/"0", choices by their internal key, lists ";"-separated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import orca
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Edit these. CATEGORY and UPDATES belong together: each presets category only
|
|
||||||
# accepts its own setting keys.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Preset category: "process", "printer", or "filament".
|
|
||||||
CATEGORY = "process"
|
|
||||||
|
|
||||||
# Target preset name for process/printer. "" keeps the currently active preset.
|
|
||||||
# For filament this optionally selects and assigns the preset for FILAMENT_SLOT;
|
|
||||||
# "" keeps the preset the slot already has.
|
|
||||||
PRESET_NAME = ""
|
|
||||||
|
|
||||||
# 1-based filament slot. Only used when CATEGORY is "filament".
|
|
||||||
FILAMENT_SLOT = 1
|
|
||||||
|
|
||||||
# Settings staged in order, then committed together.
|
|
||||||
UPDATES = (
|
|
||||||
("layer_height", "0.25"),
|
|
||||||
("wall_loops", "3"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _begin_update():
|
|
||||||
"""Bind CATEGORY's target and return a transaction to stage changes on."""
|
|
||||||
if CATEGORY not in ("process", "printer", "filament"):
|
|
||||||
raise ValueError('CATEGORY must be "process", "printer", or "filament"')
|
|
||||||
if CATEGORY == "filament":
|
|
||||||
# Filament updates are addressed by their 1-based slot number.
|
|
||||||
return orca.host.preset.filament.begin_update(FILAMENT_SLOT, PRESET_NAME)
|
|
||||||
return getattr(orca.host.preset, CATEGORY).begin_update(PRESET_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
class PresetTransactionDemo(orca.script.ScriptPluginCapabilityBase):
|
|
||||||
def get_name(self):
|
|
||||||
return "Preset transaction demo"
|
|
||||||
|
|
||||||
def execute(self):
|
|
||||||
# A plain def runs on the background Script worker, so begin_update() may
|
|
||||||
# block here through Orca's selection / unsaved-edit prompts.
|
|
||||||
try:
|
|
||||||
transaction = _begin_update()
|
|
||||||
for key, value in UPDATES:
|
|
||||||
transaction.set(key, value)
|
|
||||||
result = transaction.commit()
|
|
||||||
except Exception as error:
|
|
||||||
return orca.ExecutionResult.failure(
|
|
||||||
orca.PluginResult.RecoverableError,
|
|
||||||
"Preset transaction demo failed: %s" % error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return orca.ExecutionResult.success(
|
|
||||||
'Committed %d setting(s) to the %s preset "%s"; %s.'
|
|
||||||
% (
|
|
||||||
len(result.keys),
|
|
||||||
result.category,
|
|
||||||
result.target_name,
|
|
||||||
"it stays unsaved until you save it"
|
|
||||||
if result.dirty
|
|
||||||
else "it already matches its saved state",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@orca.plugin
|
|
||||||
class PresetTransactionDemoPackage(orca.base):
|
|
||||||
def register_capabilities(self):
|
|
||||||
orca.register_capability(PresetTransactionDemo)
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
# /// script
|
|
||||||
# requires-python = ">=3.12"
|
|
||||||
#
|
|
||||||
# [tool.orcaslicer.plugin]
|
|
||||||
# id = "preset-update-demo"
|
|
||||||
# name = "Preset Update Demo"
|
|
||||||
# description = "Browse and edit settings in process, printer, and filament presets."
|
|
||||||
# author = "OrcaSlicer contributors"
|
|
||||||
# version = "0.4.0"
|
|
||||||
# type = "script"
|
|
||||||
# ///
|
|
||||||
"""Interactive preset editor. Snapshots are read on the UI thread; edits run on execute's worker."""
|
|
||||||
|
|
||||||
import queue
|
|
||||||
import orca
|
|
||||||
|
|
||||||
|
|
||||||
PAGE = r'''<!doctype html><html><head><meta charset="utf-8"><style>
|
|
||||||
:root{color-scheme:light dark;--a:#087f78;--line:rgba(127,127,127,.25);--soft:rgba(127,127,127,.1)}
|
|
||||||
*{box-sizing:border-box}body{margin:0;font:13px/1.45 var(--orca-font,system-ui,sans-serif);display:flex;flex-direction:column;height:100vh;color:var(--orca-fg,#20242a);background:var(--orca-bg,#fafafa)}
|
|
||||||
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px}h1{font-size:16px;margin:0}.brand{color:var(--a);font-size:20px}select,input,textarea,button{font:inherit;color:inherit}select,input,textarea{background:var(--orca-bg,#fafafa);border:1px solid var(--line);border-radius:7px;padding:7px 9px}header select{margin-left:auto;min-width:140px}.bar{padding:10px 18px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--line)}.bar select{min-width:220px}.bar label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.65}.badge{padding:3px 9px;border:1px solid var(--line);border-radius:20px;opacity:.7}.layout{display:grid;grid-template-columns:minmax(230px,42%) 1fr;min-height:0;flex:1}.list{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}.search{padding:9px;border-bottom:1px solid var(--line)}.search input{width:100%}#rows{overflow:auto;padding:4px}.row{width:100%;display:flex;gap:12px;padding:7px 9px;border:0;border-left:3px solid transparent;background:transparent;text-align:left;cursor:pointer;border-radius:3px}.row:hover,.row.sel{background:var(--soft)}.row.sel{border-left-color:var(--a)}.key{flex:1;font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis}.val{max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.65;font-family:ui-monospace,monospace}.editor{padding:20px;display:flex;flex-direction:column;gap:10px;overflow:auto}.editor h2{font:600 15px ui-monospace,monospace;margin:0;overflow-wrap:anywhere}.muted{opacity:.65;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.current{font-family:ui-monospace,monospace;background:var(--soft);padding:10px;border-radius:7px;overflow-wrap:anywhere;white-space:pre-wrap}textarea{min-height:88px;resize:vertical;font-family:ui-monospace,monospace}.actions{display:flex;gap:8px}button.action{padding:7px 15px;border-radius:7px;border:1px solid var(--line);background:transparent;cursor:pointer}button.primary{background:var(--a);border-color:var(--a);color:white}button:disabled{opacity:.45;cursor:default}.hint{margin-top:auto;padding:10px;border-left:3px solid var(--a);background:var(--soft);border-radius:4px}.status{padding:8px 18px;border-top:1px solid var(--line);opacity:.75}.error{color:#c33}.empty{padding:20px;opacity:.65}@media(max-width:650px){.layout{grid-template-columns:1fr 1fr}.bar{flex-wrap:wrap}.bar select{min-width:120px;flex:1}}
|
|
||||||
</style></head><body>
|
|
||||||
<header><span class="brand">◈</span><h1>Preset editor</h1><select id="kind"><option value="process">Process</option><option value="printer">Printer</option><option value="filament">Filament</option></select><button class="action" id="close">Close</button></header>
|
|
||||||
<div class="bar"><label id="slotlabel" hidden>Slot</label><select id="slot" hidden></select><label>Preset</label><select id="preset"></select><span class="badge" id="dirty"></span><button class="action" id="reload">Refresh</button></div>
|
|
||||||
<div class="layout"><section class="list"><div class="search"><input id="search" placeholder="Search settings…"></div><div id="rows"></div></section><section class="editor"><div class="muted">Selected setting</div><h2 id="key">Choose a setting</h2><div class="muted">Current value</div><div class="current" id="current">—</div><label class="muted" for="value">New serialized value</label><textarea id="value" disabled></textarea><div class="actions"><button class="action primary" id="apply" disabled>Apply change</button><button class="action" id="reset" disabled>Reset</button></div><div class="hint">Choose a category, preset and filament slot. Edit any listed key, then apply. Orca may ask to switch presets or resolve unsaved changes. Values stay unsaved until saved in Orca.</div></section></div><footer class="status" id="status">Loading presets…</footer>
|
|
||||||
<script>
|
|
||||||
const $=id=>document.getElementById(id),S={kind:'process',slot:1,preset:'',rows:[],selected:null,busy:true,notice:'',noticeError:false};
|
|
||||||
function send(command,extra={}){$('status').textContent='Working…';S.busy=true;controls();orca.postMessage({command,kind:S.kind,slot:S.slot,preset:$('preset').value,...extra})}
|
|
||||||
function controls(){ $('kind').disabled=S.busy;$('slot').disabled=S.busy;$('preset').disabled=S.busy;$('apply').disabled=S.busy||!S.selected;$('reload').disabled=S.busy }
|
|
||||||
function load(){send('state')}
|
|
||||||
function paint(m){const notice=S.notice,noticeError=S.noticeError;S.notice='';S.noticeError=false;S.busy=false;S.kind=m.kind;S.slot=m.slot||1;S.rows=m.rows||[];S.selected=S.rows.some(r=>r.k===S.selected)?S.selected:null;
|
|
||||||
$('slotlabel').hidden=S.kind!=='filament';$('slot').hidden=S.kind!=='filament';$('slot').innerHTML=(m.slots||[]).map(x=>`<option value="${x.slot}">Slot ${x.slot} · ${esc(x.name||'Empty')}</option>`).join('');$('slot').value=S.slot;
|
|
||||||
$('preset').innerHTML=(m.presets||[]).map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');$('preset').value=m.preset_name||'';$('dirty').textContent=m.dirty?'Modified':'Saved';
|
|
||||||
const q=$('search').value.toLowerCase(),rows=S.rows.filter(r=>(r.k+' '+r.v).toLowerCase().includes(q));$('rows').innerHTML=rows.map(r=>`<button class="row ${S.selected===r.k?'sel':''}" data-key="${esc(r.k)}"><span class="key">${esc(r.k)}</span><span class="val">${esc(r.v)}</span></button>`).join('')||'<div class="empty">No matching settings</div>';
|
|
||||||
const r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r?r.k:'Choose a setting';$('current').textContent=r?r.v:'—';$('value').disabled=!r;$('value').value=r?r.v:'';$('reset').disabled=!r;controls();$('status').textContent=m.error||notice||m.message||`${S.rows.length} settings · changes remain unsaved`;$('status').className='status'+((m.error||noticeError)?' error':'') }
|
|
||||||
function esc(x){return String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
||||||
$('kind').onchange=()=>{S.kind=$('kind').value;S.selected=null;$('preset').value='';load()};$('slot').onchange=()=>{S.slot=Number($('slot').value);S.selected=null;$('preset').value='';load()};$('preset').onchange=()=>{S.selected=null;load()};$('reload').onclick=load;$('close').onclick=()=>orca.close();$('search').oninput=()=>paint({kind:S.kind,slot:S.slot,rows:S.rows,presets:[...$('preset').options].map(o=>o.value),preset_name:$('preset').value});
|
|
||||||
$('rows').onclick=e=>{let b=e.target.closest('[data-key]');if(!b)return;S.selected=b.dataset.key;let r=S.rows.find(x=>x.k===S.selected);$('key').textContent=r.k;$('current').textContent=r.v;$('value').value=r.v;$('value').disabled=false;$('reset').disabled=false;$('apply').disabled=false;document.querySelectorAll('.row').forEach(x=>x.classList.toggle('sel',x===b))};$('reset').onclick=()=>{let r=S.rows.find(x=>x.k===S.selected);if(r)$('value').value=r.v};$('apply').onclick=()=>send('apply',{key:S.selected,value:$('value').value});orca.onMessage(m=>{if(m.command==='state')paint(m);else if(m.command==='refresh'){S.notice=m.error||m.message||'';S.noticeError=!!m.error;load()}});load();
|
|
||||||
</script></body></html>'''
|
|
||||||
|
|
||||||
|
|
||||||
class PresetUpdateDemo(orca.script.ScriptPluginCapabilityBase):
|
|
||||||
window = None
|
|
||||||
_commands = None
|
|
||||||
_cancelled = False
|
|
||||||
|
|
||||||
def get_name(self):
|
|
||||||
return "Preset editor demo"
|
|
||||||
|
|
||||||
def execute(self):
|
|
||||||
if self.window is not None and self.window.is_open():
|
|
||||||
return orca.ExecutionResult.success("Preset editor is already open.")
|
|
||||||
# The queue exists before the window so on_cancelled() can always unblock this worker, even
|
|
||||||
# when a forced unload destroys the window without firing on_close.
|
|
||||||
commands = queue.Queue()
|
|
||||||
self._commands = commands
|
|
||||||
try:
|
|
||||||
if self._cancelled:
|
|
||||||
return orca.ExecutionResult.success("Preset editor closed.")
|
|
||||||
self.window = orca.host.ui.create_window(
|
|
||||||
title="Preset editor", html=PAGE, width=900, height=620,
|
|
||||||
on_message=self._on_message,
|
|
||||||
on_close=self._on_close,
|
|
||||||
)
|
|
||||||
while True:
|
|
||||||
message = commands.get()
|
|
||||||
if self._cancelled or message.get("command") == "close":
|
|
||||||
return orca.ExecutionResult.success("Preset editor closed.")
|
|
||||||
try:
|
|
||||||
notice = {"command": "refresh", "message": self._apply(message)}
|
|
||||||
except Exception as error:
|
|
||||||
notice = {"command": "refresh", "error": str(error)}
|
|
||||||
if self.window is not None and self.window.is_open():
|
|
||||||
self.window.post(notice)
|
|
||||||
except Exception as error:
|
|
||||||
return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError,
|
|
||||||
"Preset editor failed: %s" % error)
|
|
||||||
finally:
|
|
||||||
if self._commands is commands:
|
|
||||||
self._commands = None
|
|
||||||
self.window = None
|
|
||||||
|
|
||||||
# Both UI callbacks run on the wx main thread with the GIL held.
|
|
||||||
def _on_message(self, message):
|
|
||||||
message = dict(message) if isinstance(message, dict) else {}
|
|
||||||
if message.get("command") == "apply":
|
|
||||||
self._enqueue(message)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
state = self._snapshot(message)
|
|
||||||
except Exception as error:
|
|
||||||
state = {"command": "state", "kind": message.get("kind", "process"),
|
|
||||||
"slot": message.get("slot", 1), "rows": [], "presets": [], "slots": [],
|
|
||||||
"preset_name": "", "dirty": False, "error": str(error)}
|
|
||||||
window = self.window
|
|
||||||
if window is not None and window.is_open():
|
|
||||||
window.post(state)
|
|
||||||
|
|
||||||
def _on_close(self):
|
|
||||||
self._enqueue({"command": "close"})
|
|
||||||
|
|
||||||
def on_cancelled(self):
|
|
||||||
# Called before the unload drain. Enqueueing a close lets a worker parked in get() exit; a
|
|
||||||
# callback that arrives after the worker already left sees no queue and is harmless.
|
|
||||||
self._cancelled = True
|
|
||||||
self._enqueue({"command": "close"})
|
|
||||||
|
|
||||||
def _enqueue(self, message):
|
|
||||||
commands = self._commands
|
|
||||||
if commands is not None:
|
|
||||||
commands.put(message)
|
|
||||||
|
|
||||||
def _apply(self, message):
|
|
||||||
kind = message.get("kind", "process")
|
|
||||||
if kind not in ("process", "printer", "filament"):
|
|
||||||
raise ValueError("Unknown preset category")
|
|
||||||
name = message.get("preset") or ""
|
|
||||||
if not isinstance(name, str):
|
|
||||||
raise ValueError("Preset name must be a string")
|
|
||||||
key, value = message.get("key"), message.get("value")
|
|
||||||
if not isinstance(key, str) or not key or not isinstance(value, str):
|
|
||||||
raise ValueError("Setting key and serialized value required")
|
|
||||||
editor = getattr(orca.host.preset, kind)
|
|
||||||
if kind == "filament":
|
|
||||||
slot = int(message.get("slot", 1))
|
|
||||||
if slot < 1:
|
|
||||||
raise ValueError("Filament slot numbers are 1-based")
|
|
||||||
transaction = editor.begin_update(slot, name)
|
|
||||||
else:
|
|
||||||
transaction = editor.begin_update(name)
|
|
||||||
transaction.set(key, value)
|
|
||||||
result = transaction.commit()
|
|
||||||
status = "Set %s on %s. Changes remain unsaved." % (key, result.target_name)
|
|
||||||
if result.selection_changed:
|
|
||||||
status += " Orca switched the active preset."
|
|
||||||
if kind == "filament" and len(result.shared_slots) > 1:
|
|
||||||
status += " Shared by filament slots: %s." % ", ".join(str(s) for s in result.shared_slots)
|
|
||||||
return status
|
|
||||||
|
|
||||||
# Read-only snapshot. Runs on the UI thread so no live Preset or PresetBundle object is read
|
|
||||||
# from the Script worker, and never calls begin_update() or another blocking preset operation.
|
|
||||||
def _snapshot(self, message):
|
|
||||||
kind = message.get("kind", "process")
|
|
||||||
if kind not in ("process", "printer", "filament"):
|
|
||||||
raise ValueError("Unknown preset category")
|
|
||||||
try:
|
|
||||||
slot = int(message.get("slot", 1))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
slot = 1
|
|
||||||
requested = message.get("preset") or ""
|
|
||||||
if not isinstance(requested, str):
|
|
||||||
requested = ""
|
|
||||||
bundle = orca.host.preset_bundle()
|
|
||||||
slots = []
|
|
||||||
if kind == "filament":
|
|
||||||
assigned_names = list(bundle.current_filament_preset_names())
|
|
||||||
presets = list(bundle.current_filament_presets())
|
|
||||||
slots = [{"slot": index + 1,
|
|
||||||
"name": presets[index].name if presets[index] is not None
|
|
||||||
else (assigned_names[index] or None)}
|
|
||||||
for index in range(len(presets))]
|
|
||||||
if not 1 <= slot <= len(presets):
|
|
||||||
slot = 1
|
|
||||||
preset = bundle.filaments.find_preset(requested) if requested else None
|
|
||||||
if preset is None and presets:
|
|
||||||
preset = presets[slot - 1]
|
|
||||||
available = list(bundle.filaments.preset_names())
|
|
||||||
else:
|
|
||||||
collection = bundle.prints if kind == "process" else bundle.printers
|
|
||||||
available = list(collection.preset_names())
|
|
||||||
preset = collection.find_preset(requested) if requested else None
|
|
||||||
if preset is None:
|
|
||||||
preset = (bundle.current_process_preset() if kind == "process"
|
|
||||||
else bundle.current_printer_preset())
|
|
||||||
rows = []
|
|
||||||
if preset is not None:
|
|
||||||
for key in sorted(preset.config_keys()):
|
|
||||||
value = preset.config_value(key)
|
|
||||||
rows.append({"k": key, "v": "" if value is None else str(value)})
|
|
||||||
return {"command": "state", "kind": kind, "slot": slot, "slots": slots,
|
|
||||||
"presets": available, "preset_name": preset.name if preset is not None else "",
|
|
||||||
"dirty": bool(preset.is_dirty) if preset is not None else False, "rows": rows}
|
|
||||||
|
|
||||||
|
|
||||||
@orca.plugin
|
|
||||||
class PresetUpdateDemoPackage(orca.base):
|
|
||||||
def register_capabilities(self):
|
|
||||||
orca.register_capability(PresetUpdateDemo)
|
|
||||||
@@ -12,6 +12,7 @@ commands:
|
|||||||
trim delete profile files no <vendor>.json list references
|
trim delete profile files no <vendor>.json list references
|
||||||
update-index regenerate the *_list sections of <vendor>.json
|
update-index regenerate the *_list sections of <vendor>.json
|
||||||
legacy-compat write the printers user presets can be migrated back to
|
legacy-compat write the printers user presets can be migrated back to
|
||||||
|
gcode-diff print a readable markdown diff of changed custom G-code
|
||||||
|
|
||||||
options shared by several commands:
|
options shared by several commands:
|
||||||
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
|
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
|
||||||
@@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import difflib
|
||||||
import functools
|
import functools
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
@@ -192,6 +196,7 @@ VECTOR_KEYS = {
|
|||||||
|
|
||||||
PRINT_CONFIG_CPP = os.path.normpath(
|
PRINT_CONFIG_CPP = os.path.normpath(
|
||||||
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
|
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
|
||||||
|
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=None)
|
@functools.lru_cache(maxsize=None)
|
||||||
@@ -313,11 +318,11 @@ REMEDY_HINTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_error(msg):
|
def print_error(msg, file=None):
|
||||||
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
|
print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
|
||||||
|
|
||||||
def print_warning(msg):
|
def print_warning(msg, file=None):
|
||||||
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
|
print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
|
||||||
|
|
||||||
def print_info(msg):
|
def print_info(msg):
|
||||||
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
||||||
@@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# gcode-diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
|
||||||
|
GCODE_DIFF_BUDGET = 60000
|
||||||
|
# The file names a section's summary shows before "and N more".
|
||||||
|
GCODE_DIFF_SUMMARY_FILES = 3
|
||||||
|
|
||||||
|
GCODE_DIFF_HEADER = (
|
||||||
|
"## Custom G-code changes\n"
|
||||||
|
"\n"
|
||||||
|
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
|
||||||
|
"diff of each changed G-code or other text setting.\n")
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
|
def _text_option_keys():
|
||||||
|
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
|
||||||
|
(every *_gcode option, and every one it marks multiline, such as notes), plus the
|
||||||
|
ones Tab.cpp edits as code, such as filename_format."""
|
||||||
|
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
|
||||||
|
source = f.read()
|
||||||
|
keys = set()
|
||||||
|
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
|
||||||
|
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
|
||||||
|
if match and (match.group(1).endswith("_gcode")
|
||||||
|
or re.search(r"def->multiline\s*=\s*true", body)):
|
||||||
|
keys.add(match.group(1))
|
||||||
|
if "machine_start_gcode" not in keys:
|
||||||
|
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
|
||||||
|
# Tab.cpp only adds keys, so a missing file is not an error.
|
||||||
|
try:
|
||||||
|
with open(TAB_CPP, encoding="utf-8") as f:
|
||||||
|
gui = f.read()
|
||||||
|
except OSError:
|
||||||
|
gui = ""
|
||||||
|
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
|
||||||
|
gui, re.DOTALL))
|
||||||
|
return frozenset(keys)
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd, *args, stdin=None):
|
||||||
|
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
|
||||||
|
capture_output=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _blobs_at(profiles_dir, specs):
|
||||||
|
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
|
||||||
|
profiles_dir) pairs, all read by one git cat-file process."""
|
||||||
|
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
|
||||||
|
out = _git(profiles_dir, "cat-file", "--batch",
|
||||||
|
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
|
||||||
|
blobs, pos = {}, 0
|
||||||
|
for spec in specs:
|
||||||
|
end = out.index(b"\n", pos)
|
||||||
|
header = out[pos:end]
|
||||||
|
pos = end + 1
|
||||||
|
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
|
||||||
|
if header.endswith((b" missing", b" ambiguous")):
|
||||||
|
blobs[spec] = None
|
||||||
|
continue
|
||||||
|
_oid, kind, size = header.split()
|
||||||
|
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
|
||||||
|
pos += int(size) + 1
|
||||||
|
return blobs
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_profile(raw, rel, where):
|
||||||
|
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
|
||||||
|
if raw is None:
|
||||||
|
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8-sig"))
|
||||||
|
except ValueError as e:
|
||||||
|
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_profiles(profiles_dir, base, head):
|
||||||
|
"""(old path, new path) of each JSON file under profiles_dir that differs between
|
||||||
|
base and head (the working tree, untracked files included, when None); the absent
|
||||||
|
side of an added or deleted file is None, and so is a side that is not JSON. Renames
|
||||||
|
are paired."""
|
||||||
|
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
|
||||||
|
*([head] if head else []), "--", ".")
|
||||||
|
fields = out.decode("utf-8").split("\0")
|
||||||
|
pairs, i = [], 0
|
||||||
|
while i + 1 < len(fields):
|
||||||
|
status = fields[i]
|
||||||
|
old = new = fields[i + 1]
|
||||||
|
if status[0] in "RC":
|
||||||
|
new = fields[i + 2]
|
||||||
|
i += 3
|
||||||
|
else:
|
||||||
|
i += 2
|
||||||
|
old = None if status[0] == "A" or not old.endswith(".json") else old
|
||||||
|
new = None if status[0] == "D" or not new.endswith(".json") else new
|
||||||
|
if old or new:
|
||||||
|
pairs.append((old, new))
|
||||||
|
if head is None:
|
||||||
|
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
|
||||||
|
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
|
||||||
|
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def _text_lines(value):
|
||||||
|
return [line.rstrip() for line in value.split("\n")] if value else []
|
||||||
|
|
||||||
|
|
||||||
|
def _text_changes(old, new):
|
||||||
|
"""(setting, unified diff) for every G-code or other text value that differs
|
||||||
|
between two profiles; a vector option with several values gives one per value."""
|
||||||
|
changes = []
|
||||||
|
for key in list(new) + [k for k in old if k not in new]:
|
||||||
|
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
|
||||||
|
if a == b or not all(isinstance(v, str) for v in a + b):
|
||||||
|
continue
|
||||||
|
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
|
||||||
|
continue
|
||||||
|
width = max(len(a), len(b))
|
||||||
|
for i in range(width):
|
||||||
|
diff = list(difflib.unified_diff(
|
||||||
|
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
|
||||||
|
lineterm=""))[2:] # drops the ---/+++ file header
|
||||||
|
if diff:
|
||||||
|
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def _gcode_diff_section(files, setting, diff):
|
||||||
|
# A fence longer than any backtick run in the G-code cannot be closed by it.
|
||||||
|
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
|
||||||
|
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
|
||||||
|
def code(text):
|
||||||
|
return f"<code>{html.escape(text, quote=False)}</code>"
|
||||||
|
names, listing = ", ".join(map(code, files)), ""
|
||||||
|
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
|
||||||
|
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
|
||||||
|
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
|
||||||
|
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
|
||||||
|
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
|
||||||
|
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
|
||||||
|
|
||||||
|
|
||||||
|
def gcode_diff(profiles_dir, base, head=None, vendors=None):
|
||||||
|
"""Markdown with one collapsed diff per changed G-code setting between base and
|
||||||
|
head, or "" when none changed. Files whose diff of a setting is identical share
|
||||||
|
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
|
||||||
|
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
|
||||||
|
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
|
||||||
|
specs = ([(base, old) for old, _ in pairs if old]
|
||||||
|
+ [(head, new) for _, new in pairs if new and head])
|
||||||
|
blobs = _blobs_at(profiles_dir, specs) if specs else {}
|
||||||
|
|
||||||
|
def load(ref, rel):
|
||||||
|
if rel is None:
|
||||||
|
return {}
|
||||||
|
if ref:
|
||||||
|
return _parse_profile(blobs[(ref, rel)], rel, ref)
|
||||||
|
try:
|
||||||
|
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
|
||||||
|
except OSError as e:
|
||||||
|
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
groups = {} # (setting, diff) -> files, in the order git lists them
|
||||||
|
for old_rel, new_rel in pairs:
|
||||||
|
old, new = load(base, old_rel), load(head, new_rel)
|
||||||
|
if not isinstance(old, dict) or not isinstance(new, dict):
|
||||||
|
continue
|
||||||
|
for setting, diff in _text_changes(old, new):
|
||||||
|
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
|
||||||
|
if not groups:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
sections = [_gcode_diff_section(files, setting, diff)
|
||||||
|
for (setting, diff), files in groups.items()]
|
||||||
|
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
|
||||||
|
shown = 0
|
||||||
|
for section in sections:
|
||||||
|
budget -= len(section.encode("utf-8"))
|
||||||
|
if budget < 0:
|
||||||
|
break
|
||||||
|
shown += 1
|
||||||
|
note, hidden = "", len(sections) - shown
|
||||||
|
if hidden:
|
||||||
|
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
|
||||||
|
"shown because the comment would be too long for GitHub. See the Files "
|
||||||
|
"changed tab for the rest.\n")
|
||||||
|
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
|
||||||
|
|
||||||
|
|
||||||
|
def _default_gcode_diff_base(profiles_dir, head):
|
||||||
|
for main in ("upstream/main", "origin/main"):
|
||||||
|
try:
|
||||||
|
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
|
||||||
|
|
||||||
|
|
||||||
|
def run_gcode_diff(profiles_dir, vendors, base, head):
|
||||||
|
try:
|
||||||
|
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
|
||||||
|
head, vendors)
|
||||||
|
# UnicodeDecodeError means git listed a file name that is not UTF-8.
|
||||||
|
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
|
||||||
|
stderr = getattr(e, "stderr", None)
|
||||||
|
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
sys.stdout.write(markdown)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3233,6 +3455,9 @@ examples:
|
|||||||
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
|
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
|
||||||
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
|
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
|
||||||
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
|
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
|
||||||
|
orca_profile_tool.py gcode-diff > gcode.md
|
||||||
|
the changed custom G-code of the working tree since it left upstream/main,
|
||||||
|
as markdown with one collapsed diff per setting
|
||||||
|
|
||||||
after adding, renaming or deleting profile files, run in this order:
|
after adding, renaming or deleting profile files, run in this order:
|
||||||
normalize -> update-index -> generate-id -> check
|
normalize -> update-index -> generate-id -> check
|
||||||
@@ -3387,6 +3612,25 @@ def build_parser():
|
|||||||
legacy_cmd.add_argument("--check", action="store_true",
|
legacy_cmd.add_argument("--check", action="store_true",
|
||||||
help="fail if the map differs from what --base implies; write nothing")
|
help="fail if the map differs from what --base implies; write nothing")
|
||||||
|
|
||||||
|
gcode_cmd = add(
|
||||||
|
"gcode-diff", [vendor_opt, profiles_opt],
|
||||||
|
"print a readable markdown diff of changed custom G-code",
|
||||||
|
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
|
||||||
|
"setting that differs between --base and --head in each changed profile\n"
|
||||||
|
"file, one collapsed section per setting. Files whose diff of a setting is\n"
|
||||||
|
"identical share a section. Trailing spaces are removed from every line, and\n"
|
||||||
|
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
|
||||||
|
"when no such setting changed. CI posts it on profile PRs.\n"
|
||||||
|
"\n"
|
||||||
|
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
|
||||||
|
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
|
||||||
|
"string value that spans several lines.")
|
||||||
|
gcode_cmd.add_argument("--base", metavar="REF",
|
||||||
|
help="compare against this git ref (default: the merge base "
|
||||||
|
"with upstream/main, else origin/main)")
|
||||||
|
gcode_cmd.add_argument("--head", metavar="REF",
|
||||||
|
help="compare this git ref (default: the working tree)")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -3423,6 +3667,9 @@ def main(argv=None):
|
|||||||
if args.command == "legacy-compat":
|
if args.command == "legacy-compat":
|
||||||
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
|
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
|
||||||
|
|
||||||
|
if args.command == "gcode-diff":
|
||||||
|
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
|
||||||
|
|
||||||
if args.command == "fix-variant":
|
if args.command == "fix-variant":
|
||||||
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
|
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
|
||||||
strict=args.strict)
|
strict=args.strict)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
||||||
normalize, trim, update-index, fix-variant and check commands, and the subcommand
|
normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
|
||||||
dispatch that reaches them (stdlib unittest, no external deps).
|
subcommand dispatch that reaches them (stdlib unittest, no external deps).
|
||||||
|
|
||||||
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
||||||
|
|
||||||
@@ -14,9 +14,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
@@ -41,7 +44,24 @@ class Tree:
|
|||||||
os.makedirs(self.profiles)
|
os.makedirs(self.profiles)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
shutil.rmtree(self.dir, ignore_errors=True)
|
root = os.path.abspath(self.dir)
|
||||||
|
|
||||||
|
def writable_retry(func, path, _exc):
|
||||||
|
# Windows refuses to delete read-only files such as git's objects, and POSIX
|
||||||
|
# refuses to delete the entries of a read-only directory.
|
||||||
|
if not os.path.lexists(path): # already removed, as on a second cleanup
|
||||||
|
return
|
||||||
|
parent = os.path.dirname(os.path.abspath(path))
|
||||||
|
try:
|
||||||
|
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
|
||||||
|
os.chmod(parent, stat.S_IRWXU)
|
||||||
|
if not os.path.islink(path): # chmod would follow it out of the tree
|
||||||
|
os.chmod(path, stat.S_IRWXU)
|
||||||
|
func(path)
|
||||||
|
except (OSError, ValueError): # commonpath raises ValueError across drives
|
||||||
|
pass
|
||||||
|
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
|
||||||
|
shutil.rmtree(self.dir, **{hook: writable_retry})
|
||||||
|
|
||||||
def index_path(self, vendor):
|
def index_path(self, vendor):
|
||||||
return os.path.join(self.profiles, vendor + ".json")
|
return os.path.join(self.profiles, vendor + ".json")
|
||||||
@@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase):
|
|||||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# gcode-diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("git"), "git not installed")
|
||||||
|
class TestGcodeDiff(TreeCase):
|
||||||
|
START = "G28\nM104 S200\nG1 Z5"
|
||||||
|
|
||||||
|
def git(self, *args):
|
||||||
|
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
|
||||||
|
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
|
||||||
|
|
||||||
|
def printer(self, name, **extra):
|
||||||
|
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.git("add", "-A")
|
||||||
|
self.git("commit", "-q", "--allow-empty", "-m", "x")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# git's list of the variables that tie a process to a repository, asked with no
|
||||||
|
# GIT_* set since a stray GIT_DIR fails the query.
|
||||||
|
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||||
|
cls.repo_env_vars = set(subprocess.run(
|
||||||
|
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
|
||||||
|
capture_output=True, text=True).stdout.split())
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
|
||||||
|
# tool's included, at the real repository instead of the temp tree.
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
|
||||||
|
patcher = mock.patch.dict(os.environ, env, clear=True)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.git("init", "-q")
|
||||||
|
for name in ("A", "B", "C"):
|
||||||
|
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
||||||
|
self.printer("Gone", machine_end_gcode="M84")
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def diff(self):
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def test_identical_edits_share_one_section(self):
|
||||||
|
for name in ("A", "B"):
|
||||||
|
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
|
||||||
|
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
|
||||||
|
out = self.diff()
|
||||||
|
self.assertEqual(out.count("<details>"), 2)
|
||||||
|
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
|
||||||
|
"<code>machine_start_gcode</code>", out)
|
||||||
|
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
|
||||||
|
# Trailing spaces are removed.
|
||||||
|
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
|
||||||
|
self.assertIn("\n\n```diff\n@@ ", out)
|
||||||
|
self.assertIn("\n```\n\n</details>", out)
|
||||||
|
|
||||||
|
def test_nothing_is_printed_without_a_text_change(self):
|
||||||
|
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
|
||||||
|
self.assertEqual(self.diff(), "")
|
||||||
|
|
||||||
|
def test_added_and_deleted_files_show_the_whole_value(self):
|
||||||
|
# An untracked file counts as added; the validator's user dir is not a profile.
|
||||||
|
self.printer("New", machine_start_gcode="G28\nG29")
|
||||||
|
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
|
||||||
|
os.remove(self.t.path("V", "machine/Gone.json"))
|
||||||
|
out = self.diff()
|
||||||
|
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
|
||||||
|
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
||||||
|
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
||||||
|
self.assertNotIn("U.json", out)
|
||||||
|
|
||||||
|
def test_a_multi_line_value_counts_whatever_its_key(self):
|
||||||
|
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
|
||||||
|
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
|
||||||
|
|
||||||
|
def test_a_file_name_cannot_mention_a_user(self):
|
||||||
|
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
|
||||||
|
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
|
||||||
|
|
||||||
|
def test_head_compares_two_commits(self):
|
||||||
|
self.printer("A", machine_start_gcode="G28")
|
||||||
|
self.commit()
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
||||||
|
|
||||||
|
def test_a_renamed_file_diffs_against_its_old_self(self):
|
||||||
|
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
|
||||||
|
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
|
||||||
|
self.commit()
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
|
||||||
|
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
|
||||||
|
|
||||||
|
def test_a_file_renamed_off_json_loses_its_values(self):
|
||||||
|
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
|
||||||
|
self.commit()
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
||||||
|
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
||||||
|
|
||||||
|
def test_a_file_renamed_onto_json_is_new(self):
|
||||||
|
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
|
||||||
|
self.commit()
|
||||||
|
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
|
||||||
|
self.commit()
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
|
||||||
|
|
||||||
|
def test_a_batch_read_survives_paths_that_are_not_files(self):
|
||||||
|
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
|
||||||
|
("HEAD", "V/machine/Gone.json")])
|
||||||
|
self.assertIsNone(blobs[("HEAD", "V/machine")])
|
||||||
|
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
|
||||||
|
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
|
||||||
|
"M84")
|
||||||
|
|
||||||
|
def test_each_value_of_a_vector_option_is_its_own_setting(self):
|
||||||
|
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
|
||||||
|
self.commit()
|
||||||
|
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
|
||||||
|
out = self.diff()
|
||||||
|
self.assertEqual(out.count("<details>"), 1)
|
||||||
|
self.assertIn("<code>filament_start_gcode[1]</code>", out)
|
||||||
|
|
||||||
|
def test_vendor_limits_the_files(self):
|
||||||
|
self.printer("A", machine_start_gcode="G28")
|
||||||
|
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("V/machine/A.json", out)
|
||||||
|
self.assertNotIn("W/machine/X.json", out)
|
||||||
|
|
||||||
|
def test_vendor_includes_files_moved_out_of_it(self):
|
||||||
|
self.t.add_vendor("W")
|
||||||
|
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
|
||||||
|
data = self.t.read("W", "machine/A.json")
|
||||||
|
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
|
||||||
|
self.commit()
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
|
||||||
|
"--vendor", "V")
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
||||||
|
|
||||||
|
def same_edit(self, names):
|
||||||
|
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
|
||||||
|
for name in names:
|
||||||
|
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
||||||
|
self.commit()
|
||||||
|
for name in names:
|
||||||
|
self.printer(name, machine_start_gcode="G28")
|
||||||
|
out = self.diff()
|
||||||
|
return re.search(r"<summary>(.*)</summary>", out).group(1), out
|
||||||
|
|
||||||
|
def test_a_long_file_list_moves_into_the_section(self):
|
||||||
|
summary, out = self.same_edit("ABCDE")
|
||||||
|
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
|
||||||
|
self.assertNotIn("D.json", summary)
|
||||||
|
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
|
||||||
|
out)
|
||||||
|
|
||||||
|
def test_four_files_are_all_named_in_the_summary(self):
|
||||||
|
summary, _out = self.same_edit("ABCD")
|
||||||
|
self.assertIn("<code>V/machine/D.json</code> - ", summary)
|
||||||
|
self.assertNotIn("more", summary)
|
||||||
|
|
||||||
|
def test_an_error_goes_to_stderr_only(self):
|
||||||
|
err = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(err):
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertEqual(out, "")
|
||||||
|
self.assertIn("no-such-ref", err.getvalue())
|
||||||
|
|
||||||
|
def test_a_file_name_that_is_not_utf8_is_an_error(self):
|
||||||
|
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
|
||||||
|
contextlib.redirect_stderr(err):
|
||||||
|
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
||||||
|
self.assertEqual((rc, out), (1, ""))
|
||||||
|
self.assertIn("invalid continuation byte", err.getvalue())
|
||||||
|
|
||||||
|
def test_output_is_cut_to_whole_sections(self):
|
||||||
|
for name in ("A", "B", "C"):
|
||||||
|
self.printer(name, machine_start_gcode=name * 300)
|
||||||
|
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
|
||||||
|
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
|
||||||
|
out = self.diff()
|
||||||
|
self.assertEqual(out.count("<details>"), 2)
|
||||||
|
self.assertEqual(out.count("</details>"), 2)
|
||||||
|
self.assertIn("\n1 of 3 sections is not shown", out)
|
||||||
|
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
|
||||||
|
self.assertIn("\n3 of 3 sections are not shown", self.diff())
|
||||||
|
|
||||||
|
def test_cleanup_removes_the_repo(self):
|
||||||
|
# setUp's commit leaves read-only git objects, and a read-only directory blocks
|
||||||
|
# deletion on POSIX.
|
||||||
|
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
|
||||||
|
self.t.cleanup()
|
||||||
|
self.assertFalse(os.path.exists(self.t.dir))
|
||||||
|
with mock.patch.object(os, "chmod") as chmod:
|
||||||
|
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
|
||||||
|
chmod.assert_not_called()
|
||||||
|
|
||||||
|
def test_only_repository_variables_are_stripped(self):
|
||||||
|
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
|
||||||
|
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
|
||||||
|
|
||||||
|
def test_cleanup_leaves_symlink_targets_alone(self):
|
||||||
|
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
|
||||||
|
self.addCleanup(shutil.rmtree, outside, True)
|
||||||
|
target = os.path.join(outside, "f")
|
||||||
|
open(target, "w").close()
|
||||||
|
os.chmod(target, stat.S_IRUSR)
|
||||||
|
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
|
||||||
|
machine = self.t.path("V", "machine")
|
||||||
|
try:
|
||||||
|
os.symlink(target, os.path.join(machine, "link"))
|
||||||
|
except (OSError, NotImplementedError):
|
||||||
|
self.skipTest("cannot create symlinks here")
|
||||||
|
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
|
||||||
|
self.t.cleanup()
|
||||||
|
self.assertFalse(os.path.exists(self.t.dir))
|
||||||
|
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
|
||||||
|
|
||||||
|
def test_a_git_hook_environment_is_ignored(self):
|
||||||
|
scripts = os.path.join(REPO_ROOT, "scripts")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "unittest",
|
||||||
|
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
|
||||||
|
cwd=scripts, capture_output=True, text=True,
|
||||||
|
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_the_options_are_read_from_the_engine(self):
|
||||||
|
keys = apt._text_option_keys()
|
||||||
|
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
|
||||||
|
"notes", "post_process"} <= keys)
|
||||||
|
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
|
||||||
|
|
||||||
|
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
|
||||||
|
|
||||||
|
def keys_from(self, print_config, tab=None):
|
||||||
|
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
|
||||||
|
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
|
||||||
|
for path, text in zip(paths, (print_config, tab)):
|
||||||
|
if text is not None:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
apt._text_option_keys.cache_clear()
|
||||||
|
self.addCleanup(apt._text_option_keys.cache_clear)
|
||||||
|
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
|
||||||
|
mock.patch.object(apt, "TAB_CPP", paths[1]):
|
||||||
|
return apt._text_option_keys()
|
||||||
|
|
||||||
|
def test_multiline_is_read_whatever_its_spacing(self):
|
||||||
|
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
|
||||||
|
' def->multiline = true;\n')
|
||||||
|
self.assertIn("printer_notes", keys)
|
||||||
|
|
||||||
|
def test_the_code_options_of_tab_cpp_are_read(self):
|
||||||
|
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
|
||||||
|
'option.opt.is_code = true;\n'
|
||||||
|
'option = optgroup->get_option("layer_height");\n')
|
||||||
|
self.assertIn("filename_format", keys)
|
||||||
|
self.assertNotIn("layer_height", keys)
|
||||||
|
|
||||||
|
def test_a_missing_tab_cpp_is_not_an_error(self):
|
||||||
|
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
|
||||||
|
|
||||||
|
def test_markdown_in_a_file_name_stays_literal(self):
|
||||||
|
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
|
||||||
|
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
|
||||||
|
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# the real tree
|
# the real tree
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1126,6 +1126,19 @@ static void load_downward_settings_list_from_config(std::string config_file, std
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef __WXGTK__
|
||||||
|
// Virtual GPUs (VirtualBox, VMware, QEMU) cannot back WebKit's hardware DMABUF renderer.
|
||||||
|
static bool is_hypervisor_guest()
|
||||||
|
{
|
||||||
|
std::ifstream cpuinfo("/proc/cpuinfo");
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(cpuinfo, line))
|
||||||
|
if (boost::starts_with(line, "flags"))
|
||||||
|
return line.find(" hypervisor") != std::string::npos;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
int CLI::run(int argc, char **argv)
|
int CLI::run(int argc, char **argv)
|
||||||
{
|
{
|
||||||
// Mark the main thread for the debugger and for runtime checks.
|
// Mark the main thread for the debugger and for runtime checks.
|
||||||
@@ -1176,6 +1189,9 @@ int CLI::run(int argc, char **argv)
|
|||||||
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
|
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_hypervisor_guest() && WebView::SupportsForceSharedMemory())
|
||||||
|
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
|
||||||
|
|
||||||
if (x11_opt_in) {
|
if (x11_opt_in) {
|
||||||
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
|
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
|
||||||
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
|
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
|
||||||
|
|||||||
@@ -988,7 +988,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
|
|
||||||
MetadataList metadata;
|
MetadataList metadata;
|
||||||
VolumeMetadataList volumes;
|
VolumeMetadataList volumes;
|
||||||
bool auto_drop { true };
|
std::optional<bool> auto_drop;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct CutObjectInfo
|
struct CutObjectInfo
|
||||||
@@ -1208,6 +1208,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
//IdToGeometryMap m_orig_geometries; // backup & restore
|
//IdToGeometryMap m_orig_geometries; // backup & restore
|
||||||
CurrentConfig m_curr_config;
|
CurrentConfig m_curr_config;
|
||||||
IdToMetadataMap m_objects_metadata;
|
IdToMetadataMap m_objects_metadata;
|
||||||
|
std::set<int> m_items_auto_drop_off; // object ids with a build item auto_drop="0" (2.4.x layout)
|
||||||
IdToCutObjectInfoMap m_cut_object_infos;
|
IdToCutObjectInfoMap m_cut_object_infos;
|
||||||
IdToLayerHeightsProfileMap m_layer_heights_profiles;
|
IdToLayerHeightsProfileMap m_layer_heights_profiles;
|
||||||
IdToLayerConfigRangesMap m_layer_config_ranges;
|
IdToLayerConfigRangesMap m_layer_config_ranges;
|
||||||
@@ -1463,6 +1464,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
m_objects.clear();
|
m_objects.clear();
|
||||||
m_instances.clear();
|
m_instances.clear();
|
||||||
m_objects_metadata.clear();
|
m_objects_metadata.clear();
|
||||||
|
m_items_auto_drop_off.clear();
|
||||||
m_curr_metadata_name.clear();
|
m_curr_metadata_name.clear();
|
||||||
m_curr_characters.clear();
|
m_curr_characters.clear();
|
||||||
|
|
||||||
@@ -1502,6 +1504,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
m_curr_config.object_id = -1;
|
m_curr_config.object_id = -1;
|
||||||
m_curr_config.volume_id = -1;
|
m_curr_config.volume_id = -1;
|
||||||
m_objects_metadata.clear();
|
m_objects_metadata.clear();
|
||||||
|
m_items_auto_drop_off.clear();
|
||||||
m_layer_heights_profiles.clear();
|
m_layer_heights_profiles.clear();
|
||||||
m_layer_config_ranges.clear();
|
m_layer_config_ranges.clear();
|
||||||
m_brim_ear_points.clear();
|
m_brim_ear_points.clear();
|
||||||
@@ -2257,11 +2260,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
ObjectMetadata::VolumeMetadataList volumes;
|
ObjectMetadata::VolumeMetadataList volumes;
|
||||||
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
|
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
|
||||||
|
|
||||||
|
// 2.4.x stored auto_drop on build items, shared by all instances of an object; any "off" wins
|
||||||
|
const bool legacy_auto_drop = !m_items_auto_drop_off.count(object.first.second);
|
||||||
|
|
||||||
IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second);
|
IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second);
|
||||||
if (obj_metadata != m_objects_metadata.end()) {
|
if (obj_metadata != m_objects_metadata.end()) {
|
||||||
// config data has been found, this model was saved using slic3r pe
|
// config data has been found, this model was saved using slic3r pe
|
||||||
|
|
||||||
model_object->auto_drop = obj_metadata->second.auto_drop;
|
model_object->auto_drop = obj_metadata->second.auto_drop.value_or(legacy_auto_drop);
|
||||||
|
|
||||||
// apply object's name and config data
|
// apply object's name and config data
|
||||||
for (const Metadata& metadata : obj_metadata->second.metadata) {
|
for (const Metadata& metadata : obj_metadata->second.metadata) {
|
||||||
@@ -2279,6 +2285,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// config data not found, this model was not saved using slic3r pe
|
// config data not found, this model was not saved using slic3r pe
|
||||||
|
model_object->auto_drop = legacy_auto_drop;
|
||||||
|
|
||||||
// add the entire geometry as the single volume to generate
|
// add the entire geometry as the single volume to generate
|
||||||
//volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1);
|
//volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1);
|
||||||
@@ -4121,6 +4128,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR);
|
std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR);
|
||||||
Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR));
|
Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR));
|
||||||
int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR);
|
int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR);
|
||||||
|
if (const char* auto_drop = bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR); auto_drop != nullptr && !::atoi(auto_drop))
|
||||||
|
m_items_auto_drop_off.insert(object_id);
|
||||||
return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1);
|
return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4489,7 +4498,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
// int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR);
|
// int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR);
|
||||||
|
|
||||||
ObjectMetadata metadata;
|
ObjectMetadata metadata;
|
||||||
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR);
|
if (bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR) != nullptr)
|
||||||
|
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR);
|
||||||
m_objects_metadata.insert({ object_id, std::move(metadata) });
|
m_objects_metadata.insert({ object_id, std::move(metadata) });
|
||||||
m_curr_config.object_id = object_id;
|
m_curr_config.object_id = object_id;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -756,7 +756,14 @@ public:
|
|||||||
Preset& get_edited_preset() { return m_edited_preset; }
|
Preset& get_edited_preset() { return m_edited_preset; }
|
||||||
const Preset& get_edited_preset() const { return m_edited_preset; }
|
const Preset& get_edited_preset() const { return m_edited_preset; }
|
||||||
|
|
||||||
const Preset& get_selected_preset_base() const { return *get_preset_base(m_presets[m_idx_selected]); }
|
// The root of the selected preset's inherits chain, or the selected preset itself when
|
||||||
|
// the chain names a preset that is not loaded (e.g. a vendor that is not installed).
|
||||||
|
const Preset& get_selected_preset_base() const
|
||||||
|
{
|
||||||
|
const Preset &selected = m_presets[m_idx_selected];
|
||||||
|
const Preset *base = get_preset_base(selected);
|
||||||
|
return base ? *base : selected;
|
||||||
|
}
|
||||||
|
|
||||||
// Return the last saved preset.
|
// Return the last saved preset.
|
||||||
// const Preset& get_saved_preset() const { return m_saved_preset; }
|
// const Preset& get_saved_preset() const { return m_saved_preset; }
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ std::vector<float> Layers::get_zs() const
|
|||||||
|
|
||||||
size_t Layers::get_layer_id_at(float z) const
|
size_t Layers::get_layer_id_at(float z) const
|
||||||
{
|
{
|
||||||
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z < z; });
|
auto iter = std::upper_bound(m_items.begin(), m_items.end(), z, [](float z, const Item& item) { return item.z > z; });
|
||||||
return std::distance(m_items.begin(), iter);
|
return std::distance(m_items.begin(), iter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
#include "GUI.hpp"
|
#include "GUI.hpp"
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
#include "I18N.hpp"
|
#include "I18N.hpp"
|
||||||
#include "ImGuiWrapper.hpp"
|
|
||||||
#include "MainFrame.hpp"
|
#include "MainFrame.hpp"
|
||||||
#include "NativeCommands.hpp"
|
#include "NativeCommands.hpp"
|
||||||
#include "Notebook.hpp"
|
#include "Notebook.hpp"
|
||||||
#include "NotificationManager.hpp"
|
|
||||||
#include "OptionsGroup.hpp"
|
#include "OptionsGroup.hpp"
|
||||||
#include "Plater.hpp"
|
#include "Plater.hpp"
|
||||||
#include "SettingsIndex.hpp"
|
#include "SettingsIndex.hpp"
|
||||||
@@ -101,45 +99,6 @@ std::string find_loaded_source_name(PluginManager& manager, const std::string& p
|
|||||||
return plugin_key;
|
return plugin_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translates a Script capability outcome into the action layer's result vocabulary. Shared by
|
|
||||||
// the synchronous run path and the async completion notification.
|
|
||||||
AppActionRunResult script_outcome_to_action_result(const ScriptCompletion& completion)
|
|
||||||
{
|
|
||||||
// Cancelled by PluginManager (unload/shutdown): expected termination, nothing to show.
|
|
||||||
if (completion.cancelled)
|
|
||||||
return {};
|
|
||||||
|
|
||||||
if (!completion.error.empty())
|
|
||||||
return {AppActionRunResult::Level::Error, from_u8(completion.error)};
|
|
||||||
|
|
||||||
const ExecutionResult& result = completion.result;
|
|
||||||
const bool skipped = result.status == PluginResult::Skipped;
|
|
||||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
|
||||||
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
|
|
||||||
result.message.empty() ? fallback : from_u8(result.message)};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Surfaces the eventual outcome of an async script launched from the speed dial. That launch
|
|
||||||
// already returned (the UI was not blocked), so the finish is reported as a notification. A
|
|
||||||
// cancellation is silent.
|
|
||||||
void notify_script_outcome(const ScriptCompletion& completion)
|
|
||||||
{
|
|
||||||
if (completion.cancelled)
|
|
||||||
return;
|
|
||||||
if (wxTheApp == nullptr || wxGetApp().is_closing())
|
|
||||||
return;
|
|
||||||
|
|
||||||
Plater* plater = wxGetApp().plater();
|
|
||||||
if (plater == nullptr || wxGetApp().imgui() == nullptr || !wxGetApp().imgui()->display_initialized())
|
|
||||||
return;
|
|
||||||
|
|
||||||
const AppActionRunResult action_result = script_outcome_to_action_result(completion);
|
|
||||||
const auto level = action_result.level == AppActionRunResult::Level::Error
|
|
||||||
? NotificationManager::NotificationLevel::ErrorNotificationLevel
|
|
||||||
: NotificationManager::NotificationLevel::RegularNotificationLevel;
|
|
||||||
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification, level, into_u8(action_result.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
|
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
|
||||||
// (identity), so a plugin display-name change does not re-key the action.
|
// (identity), so a plugin display-name change does not re-key the action.
|
||||||
struct PluginScriptAction : AppAction
|
struct PluginScriptAction : AppAction
|
||||||
@@ -165,36 +124,15 @@ struct PluginScriptAction : AppAction
|
|||||||
|
|
||||||
AppActionRunResult run(const std::string& /*param*/) const override
|
AppActionRunResult run(const std::string& /*param*/) const override
|
||||||
{
|
{
|
||||||
struct Outcome
|
std::string error;
|
||||||
{
|
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
|
||||||
bool async = false;
|
if (!error.empty())
|
||||||
bool completed = false;
|
return {AppActionRunResult::Level::Error, from_u8(error)};
|
||||||
AppActionRunResult result;
|
|
||||||
};
|
|
||||||
auto outcome = std::make_shared<Outcome>();
|
|
||||||
|
|
||||||
auto on_done = [outcome](const ScriptCompletion& completion) {
|
const bool skipped = result.status == PluginResult::Skipped;
|
||||||
outcome->result = script_outcome_to_action_result(completion);
|
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||||
outcome->completed = true;
|
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
|
||||||
if (outcome->async)
|
result.message.empty() ? fallback : from_u8(result.message)};
|
||||||
notify_script_outcome(completion);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PluginManager::ScriptLaunchResult launch = PluginManager::instance().launch_script_capability(plugin_key, capability, on_done);
|
|
||||||
if (launch.launched_async) {
|
|
||||||
// The completion is delivered on this (GUI) thread once the (sync or async) invocation
|
|
||||||
// finishes, so marking `async` here cannot race with on_done. The speed dial has already
|
|
||||||
// shown a result by then; the final outcome arrives as a notification (a cancellation is
|
|
||||||
// silent).
|
|
||||||
outcome->async = true;
|
|
||||||
if (outcome->completed)
|
|
||||||
return outcome->result;
|
|
||||||
return {AppActionRunResult::Level::Info, _L("Script plugin started.")};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The launch failed inline; on_done produced the (error) result.
|
|
||||||
on_done(launch.outcome);
|
|
||||||
return outcome->result;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
|
|||||||
m_printer_name_to_preset.clear();
|
m_printer_name_to_preset.clear();
|
||||||
for (const Preset &printer_preset : printer_presets) {
|
for (const Preset &printer_preset : printer_presets) {
|
||||||
if (!printer_preset.is_visible) continue;
|
if (!printer_preset.is_visible) continue;
|
||||||
if (preset_bundle->printers.get_preset_base(printer_preset)->name != printer_preset.name) continue;
|
if (preset_bundle->printers.get_preset_base(printer_preset) != &printer_preset) continue;
|
||||||
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
|
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
|
||||||
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
|
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
|
||||||
printer_choice.push_back(from_u8(printer_model->value));
|
printer_choice.push_back(from_u8(printer_model->value));
|
||||||
@@ -4962,7 +4962,7 @@ void CreatePresetForPrinterDialog::get_visible_printer_and_compatible_filament_p
|
|||||||
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
|
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
|
||||||
ConfigOptionStrings *filament_types;
|
ConfigOptionStrings *filament_types;
|
||||||
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
|
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
|
||||||
if (filament_preset_base == &filament_preset) {
|
if (filament_preset_base == &filament_preset || filament_preset_base == nullptr) {
|
||||||
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
|
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
|
||||||
} else {
|
} else {
|
||||||
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
|
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
#include "Search.hpp"
|
#include "Search.hpp"
|
||||||
#include "BitmapCache.hpp"
|
#include "BitmapCache.hpp"
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
|
#include "KeyChord.hpp"
|
||||||
|
|
||||||
#include <nanosvg/nanosvg.h>
|
#include <nanosvg/nanosvg.h>
|
||||||
#include <nanosvg/nanosvgrast.h>
|
#include <nanosvg/nanosvgrast.h>
|
||||||
@@ -2134,6 +2135,27 @@ bool ImGuiWrapper::want_any_input() const
|
|||||||
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
|
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ImGuiWrapper::text_input_active() const
|
||||||
|
{
|
||||||
|
const ImGuiID id = ImGui::GetActiveID();
|
||||||
|
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImGuiWrapper::modal_popup_open() const
|
||||||
|
{
|
||||||
|
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
|
||||||
|
// frame drew it, which NewFrame() moves from Active to WasActive.
|
||||||
|
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
|
||||||
|
if (modal == nullptr)
|
||||||
|
return false;
|
||||||
|
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
|
||||||
|
{
|
||||||
|
return modal_popup_open() || (chord.edits_text() && text_input_active());
|
||||||
|
}
|
||||||
|
|
||||||
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
|
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
|
||||||
{
|
{
|
||||||
auto item = m_custom_glyph_rects_ids.find(tex_id);
|
auto item = m_custom_glyph_rects_ids.find(tex_id);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ struct ImRect;
|
|||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
namespace GUI {
|
namespace GUI {
|
||||||
|
|
||||||
|
struct KeyChord;
|
||||||
|
|
||||||
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
|
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
|
||||||
|
|
||||||
@@ -219,6 +220,12 @@ public:
|
|||||||
bool want_keyboard() const;
|
bool want_keyboard() const;
|
||||||
bool want_text_input() const;
|
bool want_text_input() const;
|
||||||
bool want_any_input() const;
|
bool want_any_input() const;
|
||||||
|
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
|
||||||
|
bool text_input_active() const;
|
||||||
|
// True while an ImGui modal popup is on screen.
|
||||||
|
bool modal_popup_open() const;
|
||||||
|
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
|
||||||
|
bool owns_key(const KeyChord& chord) const;
|
||||||
|
|
||||||
// Optional inputs are used for set up value inside of an optional, with default value
|
// Optional inputs are used for set up value inside of an optional, with default value
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
|
|||||||
using Section = ShortcutSection;
|
using Section = ShortcutSection;
|
||||||
|
|
||||||
if (wxGetApp().is_editor()) {
|
if (wxGetApp().is_editor()) {
|
||||||
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, {
|
page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
|
||||||
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
|
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
|
||||||
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
|
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
|
||||||
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
|
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
|
||||||
@@ -438,17 +438,16 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
|
|||||||
SetBackgroundColour(*wxWHITE);
|
SetBackgroundColour(*wxWHITE);
|
||||||
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
|
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
|
||||||
|
|
||||||
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use.
|
// A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
|
||||||
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
|
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
|
||||||
const wxString advice = global_key_advice();
|
const wxString advice = global_key_advice();
|
||||||
const wxString typing = _L("Global shortcuts also apply while typing.");
|
|
||||||
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
|
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
|
||||||
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms.");
|
m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
|
||||||
m_rejection = rule + "\n" + advice;
|
m_rejection = rule + "\n" + advice;
|
||||||
|
|
||||||
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
|
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
|
||||||
int width = FromDIP(450);
|
int width = FromDIP(450);
|
||||||
for (const wxString& sentence : { typing, rule, advice }) {
|
for (const wxString& sentence : { rule, advice }) {
|
||||||
int extent = 0;
|
int extent = 0;
|
||||||
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
|
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
|
||||||
width = std::max(width, extent);
|
width = std::max(width, extent);
|
||||||
|
|||||||
@@ -187,6 +187,40 @@ bool KeyChord::is_menu_accelerator() const
|
|||||||
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
|
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool KeyChord::edits_text() const
|
||||||
|
{
|
||||||
|
if (!valid())
|
||||||
|
return false;
|
||||||
|
if (!is_menu_accelerator())
|
||||||
|
return true;
|
||||||
|
const int mods = modifiers & ~wxMOD_SHIFT;
|
||||||
|
switch (key) {
|
||||||
|
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
|
||||||
|
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
|
||||||
|
#ifdef __APPLE__
|
||||||
|
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
|
||||||
|
#else
|
||||||
|
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
||||||
|
#endif
|
||||||
|
case WXK_INSERT:
|
||||||
|
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
||||||
|
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
|
||||||
|
return mods == wxMOD_NONE;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!is_printable(key))
|
||||||
|
return false;
|
||||||
|
#ifdef __APPLE__
|
||||||
|
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
|
||||||
|
return true; // Option types a character, Control runs the Cocoa text key bindings
|
||||||
|
#else
|
||||||
|
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
|
||||||
|
return true; // AltGr types a character
|
||||||
|
#endif
|
||||||
|
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
// Only a chord the desktop acts on while still delivering it to the app belongs here.
|
// Only a chord the desktop acts on while still delivering it to the app belongs here.
|
||||||
bool KeyChord::is_system_shortcut() const
|
bool KeyChord::is_system_shortcut() const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ struct KeyChord
|
|||||||
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
|
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
|
||||||
// swallowing typing in text fields.
|
// swallowing typing in text fields.
|
||||||
bool is_menu_accelerator() const;
|
bool is_menu_accelerator() const;
|
||||||
|
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
|
||||||
|
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
|
||||||
|
bool edits_text() const;
|
||||||
// True for a chord the desktop acts on although the app receives it, so a binding would
|
// True for a chord the desktop acts on although the app receives it, so a binding would
|
||||||
// take it from the system.
|
// take it from the system.
|
||||||
bool is_system_shortcut() const;
|
bool is_system_shortcut() const;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
#include <wx/panel.h>
|
#include <wx/panel.h>
|
||||||
#include <wx/settings.h>
|
#include <wx/settings.h>
|
||||||
#include <wx/textentry.h>
|
#include <wx/textentry.h>
|
||||||
|
#include <wx/spinctrl.h>
|
||||||
#include <wx/notebook.h>
|
#include <wx/notebook.h>
|
||||||
#include <wx/listbook.h>
|
#include <wx/listbook.h>
|
||||||
#include <wx/simplebook.h>
|
#include <wx/simplebook.h>
|
||||||
@@ -107,6 +108,8 @@
|
|||||||
#include "../Utils/PrintHost.hpp"
|
#include "../Utils/PrintHost.hpp"
|
||||||
|
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
|
#include "ImGuiWrapper.hpp"
|
||||||
|
#include "OpenGLManager.hpp"
|
||||||
#include "Shortcuts.hpp"
|
#include "Shortcuts.hpp"
|
||||||
#include "UnsavedChangesDialog.hpp"
|
#include "UnsavedChangesDialog.hpp"
|
||||||
#include "PublishSettingsDialog.hpp"
|
#include "PublishSettingsDialog.hpp"
|
||||||
@@ -184,18 +187,31 @@ enum class ERescaleTarget
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
bool focus_is_text_field(wxWindow* focus)
|
||||||
|
{
|
||||||
|
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
|
||||||
|
return true; // a web page can have its own text fields
|
||||||
|
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
|
||||||
|
return true; // the native Windows spin control is not a wxTextEntry
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
|
||||||
|
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
|
||||||
|
{
|
||||||
|
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
|
||||||
|
return nullptr;
|
||||||
|
return wxGetApp().imgui();
|
||||||
|
}
|
||||||
|
|
||||||
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
|
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
|
||||||
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
|
// controls, so only take Space when the focused window has no keyboard-activation meaning of its
|
||||||
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
|
// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
|
||||||
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
|
// itself does too, so Space still opens the dial on any page.
|
||||||
bool focus_keeps_space(wxWindow* focus)
|
bool focus_keeps_space(wxWindow* focus)
|
||||||
{
|
{
|
||||||
if (!focus)
|
if (!focus)
|
||||||
return false;
|
return false;
|
||||||
if (dynamic_cast<wxTextEntryBase*>(focus))
|
|
||||||
return true; // typing a space into a text field
|
|
||||||
if (dynamic_cast<wxWebView*>(focus))
|
|
||||||
return true; // web content scrolls and hosts its own text fields
|
|
||||||
if (dynamic_cast<::Button*>(focus))
|
if (dynamic_cast<::Button*>(focus))
|
||||||
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
|
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
|
||||||
if (dynamic_cast<StaticBox*>(focus))
|
if (dynamic_cast<StaticBox*>(focus))
|
||||||
@@ -816,16 +832,12 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
|
|||||||
|
|
||||||
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
|
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
|
||||||
{
|
{
|
||||||
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so
|
// CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
|
||||||
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame:
|
// the keys ImGui owns to the focused canvas.
|
||||||
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
|
|
||||||
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
|
|
||||||
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
|
|
||||||
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
|
|
||||||
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
|
|
||||||
wxWindow* const focus = wxWindow::FindFocus();
|
wxWindow* const focus = wxWindow::FindFocus();
|
||||||
ImGuiWrapper* const imgui = wxGetApp().imgui();
|
if (chord.edits_text() && focus_is_text_field(focus))
|
||||||
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr)
|
return false;
|
||||||
|
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
|
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
|
|||||||
return m_context;
|
return m_context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
|
||||||
|
|
||||||
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
||||||
{
|
{
|
||||||
int antialiasing_samples = 4;
|
int antialiasing_samples = 4;
|
||||||
@@ -470,12 +472,17 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
|||||||
attribList[samples_value_idx] = 0;
|
attribList[samples_value_idx] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS);
|
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
|
||||||
// The GL canvas paints its entire surface, so background erasing is unnecessary.
|
// The GL canvas paints its entire surface, so background erasing is unnecessary.
|
||||||
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
|
||||||
|
{
|
||||||
|
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
void OpenGLManager::detect_multisample(int* attribList)
|
void OpenGLManager::detect_multisample(int* attribList)
|
||||||
{
|
{
|
||||||
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
|
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ public:
|
|||||||
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
|
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
|
||||||
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
|
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
|
||||||
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
|
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
|
||||||
|
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
|
||||||
|
static bool is_scene_canvas(const wxWindow* window);
|
||||||
static const GLInfo& get_gl_info() { return s_gl_info; }
|
static const GLInfo& get_gl_info() { return s_gl_info; }
|
||||||
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
|
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
|
||||||
|
|
||||||
|
|||||||
@@ -1332,21 +1332,6 @@ boost::any ConfigOptionsGroup::get_config_value2(const DynamicPrintConfig& confi
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigOptionsGroup::commit_config_value(const t_config_option_key& opt_key, int opt_index)
|
|
||||||
{
|
|
||||||
if (m_config == nullptr)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// m_opt_map is filled while the option lines are created, so it still resolves the option when
|
|
||||||
// the group's controls (and thus its fields) have not been built yet.
|
|
||||||
const std::string opt_id = option_id_in_map(m_opt_map, opt_key, opt_index);
|
|
||||||
if (opt_id.empty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
on_change_OG(opt_id, get_config_value2(*m_config, opt_key, opt_index));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index)
|
Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index)
|
||||||
{
|
{
|
||||||
Field* field = get_field(opt_key);
|
Field* field = get_field(opt_key);
|
||||||
|
|||||||
@@ -119,37 +119,6 @@ using column_t = std::function<wxWindow*(wxWindow* parent, const Line&)>;
|
|||||||
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
||||||
using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
|
using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
|
||||||
|
|
||||||
// The option id a group's option map holds for opt_key, and the index it carries. `opt_index` is
|
|
||||||
// an in/out parameter: a negative value asks for the option's single entry, an explicit one selects
|
|
||||||
// that index. Returns an empty string when the map does not own the option, or when a negative index
|
|
||||||
// matches several entries -- a vector option the GUI edits one index at a time, which has no single
|
|
||||||
// value to commit. A group fills the map while its option lines are created (OptionsGroup::append_line
|
|
||||||
// / ConfigOptionsGroup::get_option), so this resolves an option even before its controls (and its
|
|
||||||
// field) have been built.
|
|
||||||
inline std::string option_id_in_map(const t_opt_map& options, const t_config_option_key& opt_key, int& opt_index)
|
|
||||||
{
|
|
||||||
if (opt_index >= 0) {
|
|
||||||
for (const auto& opt : options) {
|
|
||||||
if (opt.second.first == opt_key && opt.second.second == opt_index) {
|
|
||||||
opt_index = opt.second.second;
|
|
||||||
return opt.first;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string opt_id;
|
|
||||||
for (const auto& opt : options) {
|
|
||||||
if (opt.second.first != opt_key)
|
|
||||||
continue;
|
|
||||||
if (!opt_id.empty())
|
|
||||||
return {}; // several entries: a vector option, not one value
|
|
||||||
opt_id = opt.first;
|
|
||||||
opt_index = opt.second.second;
|
|
||||||
}
|
|
||||||
return opt_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
class OptionsGroup {
|
class OptionsGroup {
|
||||||
public:
|
public:
|
||||||
const bool staticbox{true};
|
const bool staticbox{true};
|
||||||
@@ -363,15 +332,6 @@ public:
|
|||||||
boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||||
// BBS: restore all pages in preset
|
// BBS: restore all pages in preset
|
||||||
boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||||
// Re-run this group's committed-change path for one of its options without a widget event:
|
|
||||||
// build the option's value from the config this group edits (get_config_value2, the same form
|
|
||||||
// the option's field reports) and dispatch on_change_OG(), so the group's option-specific
|
|
||||||
// conversion (e.g. the printer_agent canonicalization) and its registered change handler run
|
|
||||||
// exactly as for a hand edit. The option map is filled while the option lines are created, so
|
|
||||||
// this resolves an option whose field is not built yet (a lazy page, or an option the GUI has
|
|
||||||
// no field for). Returns false when this group does not own the option, or when opt_index < 0
|
|
||||||
// matches several entries (a vector option the GUI edits index by index has no single value).
|
|
||||||
bool commit_config_value(const t_config_option_key& opt_key, int opt_index = -1);
|
|
||||||
Field* get_fieldc(const t_config_option_key& opt_key, int opt_index);
|
Field* get_fieldc(const t_config_option_key& opt_key, int opt_index);
|
||||||
std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
|
std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
|
||||||
|
|
||||||
|
|||||||
@@ -21178,7 +21178,12 @@ void Plater::print_job_finished(wxCommandEvent &evt)
|
|||||||
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
|
Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager();
|
||||||
if (!dev) return;
|
if (!dev) return;
|
||||||
|
|
||||||
dev->set_selected_machine(evt.GetString().ToStdString());
|
const std::string dev_id = evt.GetString().ToStdString();
|
||||||
|
MachineObject* selected = dev->get_selected_machine();
|
||||||
|
// After a LAN print, the completion event reselects the same printer.
|
||||||
|
// Keep the active session while asynchronous status traffic may still be in flight.
|
||||||
|
if (!selected || selected->get_dev_id() != dev_id || selected->connection_type() != "lan")
|
||||||
|
dev->set_selected_machine(dev_id);
|
||||||
p->main_frame->request_select_tab(TAB_ID_MONITOR);
|
p->main_frame->request_select_tab(TAB_ID_MONITOR);
|
||||||
// Selects the status page on a built Device tab; one built by the switch starts there.
|
// Selects the status page on a built Device tab; one built by the switch starts there.
|
||||||
MonitorPanel* curr_monitor = MonitorPanel::if_built();
|
MonitorPanel* curr_monitor = MonitorPanel::if_built();
|
||||||
|
|||||||
@@ -1158,7 +1158,10 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
|||||||
{
|
{
|
||||||
PluginManager& manager = PluginManager::instance();
|
PluginManager& manager = PluginManager::instance();
|
||||||
|
|
||||||
auto complete_with_error = [this, &manager, plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
std::string error;
|
||||||
|
ExecutionResult result;
|
||||||
|
|
||||||
|
auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
||||||
const std::string normalized_error = plugin_error.empty() ? _u8L("Script plugin failed.") : plugin_error;
|
const std::string normalized_error = plugin_error.empty() ? _u8L("Script plugin failed.") : plugin_error;
|
||||||
if (!manager.set_plugin_error(plugin_key, normalized_error))
|
if (!manager.set_plugin_error(plugin_key, normalized_error))
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
||||||
@@ -1174,53 +1177,32 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
|
|||||||
show_status(message, "error");
|
show_status(message, "error");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always runs on the GUI thread. For a started invocation (sync `def execute` or `async def`)
|
|
||||||
// the runtime delivers it later; it is called inline here only when the launch itself failed.
|
|
||||||
auto finish = [this, alive = m_alive, plugin_key, &manager, complete_with_error](const ScriptCompletion& completion) {
|
|
||||||
if (!alive->load())
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Cancelled by PluginManager (unload/shutdown): expected termination. Report neither an
|
|
||||||
// error nor a normal success, and do not touch the plugin's error/state.
|
|
||||||
if (completion.cancelled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!completion.error.empty()) {
|
|
||||||
complete_with_error(completion.error, wxString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExecutionResult& result = completion.result;
|
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
|
||||||
<< " status=" << static_cast<int>(result.status) << " message=" << result.message
|
|
||||||
<< " data=" << result.data;
|
|
||||||
|
|
||||||
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
|
|
||||||
if (failed) {
|
|
||||||
complete_with_error(result.message, wxString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.clear_plugin_error(plugin_key);
|
|
||||||
send_plugins();
|
|
||||||
|
|
||||||
const bool skipped = result.status == PluginResult::Skipped;
|
|
||||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
|
||||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
|
||||||
show_status(message, skipped ? "info" : "success");
|
|
||||||
};
|
|
||||||
|
|
||||||
PluginManager::ScriptLaunchResult launch;
|
|
||||||
{
|
{
|
||||||
wxBusyCursor busy;
|
wxBusyCursor busy;
|
||||||
launch = manager.launch_script_capability(plugin_key, capability_name, finish);
|
result = manager.run_script_capability(plugin_key, capability_name, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every started invocation — a synchronous `def execute` included — completes later through
|
if (!error.empty()) {
|
||||||
// `finish`; only a launch failure is reported inline. Return without blocking.
|
complete_with_error(error, wxString());
|
||||||
if (!launch.launched_async)
|
return;
|
||||||
finish(launch.outcome);
|
}
|
||||||
|
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
||||||
|
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
|
||||||
|
|
||||||
|
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError || !error.empty();
|
||||||
|
if (failed) {
|
||||||
|
complete_with_error(result.message, wxString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.clear_plugin_error(plugin_key);
|
||||||
|
send_plugins();
|
||||||
|
|
||||||
|
const bool skipped = result.status == PluginResult::Skipped;
|
||||||
|
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||||
|
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
||||||
|
show_status(message, skipped ? "info" : "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
||||||
|
|||||||
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
|
|||||||
{
|
{
|
||||||
m_printer_last_select = "";
|
m_printer_last_select = "";
|
||||||
update_select_layout(nullptr);
|
update_select_layout(nullptr);
|
||||||
m_printer_box->GetPrinterComboBox()->SetTextLabel("");
|
m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||||
|
|||||||
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
m_printer_last_select = "";
|
m_printer_last_select = "";
|
||||||
m_comboBox_printer->SetTextLabel("");
|
m_comboBox_printer->SetValue(_L("No available printers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <stdexcept>
|
|
||||||
#include "libslic3r/LifecycleEvents.hpp"
|
#include "libslic3r/LifecycleEvents.hpp"
|
||||||
#include "libslic3r/Point.hpp"
|
#include "libslic3r/Point.hpp"
|
||||||
#include "slic3r/GUI/SettingsIndex.hpp"
|
#include "slic3r/GUI/SettingsIndex.hpp"
|
||||||
@@ -1646,138 +1645,6 @@ void Tab::load_config(const DynamicPrintConfig& config)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The option keys a preset of `type` may hold. The tab's config shares the global
|
|
||||||
// print_config_def with every other preset type, so membership has to be checked against the
|
|
||||||
// preset type's own key list instead of the config definition, or a printer setting could be
|
|
||||||
// written into the process preset.
|
|
||||||
static const std::vector<std::string>& preset_type_options(Preset::Type type)
|
|
||||||
{
|
|
||||||
static const std::vector<std::string> none;
|
|
||||||
switch (type) {
|
|
||||||
case Preset::TYPE_PRINT: return Preset::print_options();
|
|
||||||
case Preset::TYPE_FILAMENT: return Preset::filament_options();
|
|
||||||
case Preset::TYPE_PRINTER: return Preset::printer_options();
|
|
||||||
default: return none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static const char* preset_type_name(Preset::Type type)
|
|
||||||
{
|
|
||||||
switch (type) {
|
|
||||||
case Preset::TYPE_PRINT: return "process";
|
|
||||||
case Preset::TYPE_FILAMENT: return "filament";
|
|
||||||
case Preset::TYPE_PRINTER: return "printer";
|
|
||||||
default: return "preset";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit one option whose new value already sits in m_config through the same path a hand edit
|
|
||||||
// runs. The key-specific behaviour (ConfigOptionsGroup::on_change_OG, including the printer_agent
|
|
||||||
// canonicalization, the group's pre-checks and Tab::on_value_change()) runs exactly as for a hand
|
|
||||||
// edit. A built field supplies the field's typed/clamped value; otherwise the owning
|
|
||||||
// ConfigOptionsGroup builds the value from the config it edits and dispatches the same
|
|
||||||
// on_change_OG(). The option's page is reloaded from the config just committed first, so a built
|
|
||||||
// field reports the committed value rather than a stale one. No tab-wide refresh happens here: the
|
|
||||||
// caller does it once for the whole batch.
|
|
||||||
bool Tab::commit_preset_config_value(const std::string& opt_key)
|
|
||||||
{
|
|
||||||
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
|
|
||||||
field_page->reload_config();
|
|
||||||
field->field_changed();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const PageShp& page : m_pages)
|
|
||||||
for (const ConfigOptionsGroupShp& group : page->m_optgroups)
|
|
||||||
if (group->commit_config_value(opt_key))
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set one option of the currently edited preset from its serialized string form, then run the
|
|
||||||
// same update path a hand edit runs. The edited preset is not saved: it only becomes dirty,
|
|
||||||
// exactly as if the field had been edited by hand.
|
|
||||||
void Tab::apply_preset_config_value(const std::string& opt_key, const std::string& value)
|
|
||||||
{
|
|
||||||
apply_preset_config_values({{opt_key, value}});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply a batch of settings as one transaction: parse and validate the whole batch first, then
|
|
||||||
// commit it through the same per-setting path apply_preset_config_value() uses, restoring the edited
|
|
||||||
// preset and the state derived from it if a setting fails. See the header for the contract and its
|
|
||||||
// limits.
|
|
||||||
void Tab::apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values)
|
|
||||||
{
|
|
||||||
if (m_config == nullptr)
|
|
||||||
throw std::runtime_error("The preset configuration is not available");
|
|
||||||
if (wxGetApp().plater() == nullptr)
|
|
||||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
|
||||||
|
|
||||||
if (values.empty())
|
|
||||||
return;
|
|
||||||
|
|
||||||
const std::vector<std::string>& options = preset_type_options(m_type);
|
|
||||||
|
|
||||||
// Preparation: reject every batch that cannot be applied before touching the edited preset, so a
|
|
||||||
// bad key or value fails without a partial write. The key must belong to this preset type, it must
|
|
||||||
// not repeat, and each value is strict-parsed into a scratch copy (the strict deserializer can
|
|
||||||
// already have mutated an option when it rejects a bad value, so the check has to run somewhere
|
|
||||||
// the edited preset is not affected). Callback failures cannot be prepared this way: a setting's
|
|
||||||
// on_change callback is a GUI method, not a pure function of the config, so whether it throws is
|
|
||||||
// only known by running it. That residual case is handled by the rollback below.
|
|
||||||
std::set<std::string> seen;
|
|
||||||
DynamicPrintConfig scratch(*m_config);
|
|
||||||
for (const auto& kv : values) {
|
|
||||||
const std::string& opt_key = kv.first;
|
|
||||||
if (std::find(options.begin(), options.end(), opt_key) == options.end())
|
|
||||||
throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key));
|
|
||||||
if (!seen.insert(opt_key).second)
|
|
||||||
throw std::invalid_argument(format("%1% preset setting \"%2%\" is set more than once in the same update",
|
|
||||||
preset_type_name(m_type), opt_key));
|
|
||||||
try {
|
|
||||||
scratch.set_deserialize_strict(opt_key, kv.second);
|
|
||||||
} catch (const ConfigurationError& err) {
|
|
||||||
throw std::invalid_argument(err.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rollback boundary: snapshot the whole edited config, not just the keys the batch names, because a
|
|
||||||
// key-specific callback can adjust related settings of the edited preset (mutually exclusive
|
|
||||||
// options, compatibility, and so on). A failure restores this config and then recomputes the state
|
|
||||||
// derived from it.
|
|
||||||
DynamicPrintConfig before(*m_config);
|
|
||||||
try {
|
|
||||||
for (const auto& kv : values) {
|
|
||||||
m_config->set_deserialize_strict(kv.first, kv.second);
|
|
||||||
if (!commit_preset_config_value(kv.first))
|
|
||||||
// The GUI edits this setting through a custom widget rather than a group (e.g.
|
|
||||||
// compatible_printers), so take the same shared no-field commit path the widget uses.
|
|
||||||
// The value was written above, so saved mode skips the write and keeps the path's
|
|
||||||
// compatibility recompute, dirty flag, preset-combo and plater updates.
|
|
||||||
load_key_value(kv.first, boost::any(), /*saved_value=*/true);
|
|
||||||
}
|
|
||||||
} catch (...) {
|
|
||||||
*m_config = before;
|
|
||||||
// A callback can rewrite state derived from the config (compatibility marks, the preset
|
|
||||||
// selector, dependent tabs, the Plater and, on the printer tab, the printer agent) before it
|
|
||||||
// throws. Recompute that derived state from the restored config so none of it is left
|
|
||||||
// reflecting the failed batch. Effects that are not a function of the config (a dialog the
|
|
||||||
// user already accepted, an undo snapshot, a command already sent to a device, the target
|
|
||||||
// selection) cannot be reversed here; see the header.
|
|
||||||
m_preset_bundle->update_compatible(PresetSelectCompatibleType::Never);
|
|
||||||
reload_config();
|
|
||||||
update_dirty();
|
|
||||||
update();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the batch's final state once. The commit paths above already maintained the dirty flag,
|
|
||||||
// preset combo and, on the printer tab, the printer-agent switch; this reloads the visible page
|
|
||||||
// and pushes the config to the Plater.
|
|
||||||
reload_config();
|
|
||||||
update_dirty();
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
|
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
|
||||||
void Tab::reload_config()
|
void Tab::reload_config()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -424,40 +424,6 @@ public:
|
|||||||
//BBS update plater presets if update_plater_presets = true
|
//BBS update plater presets if update_plater_presets = true
|
||||||
void update_tab_ui(bool update_plater_presets = false);
|
void update_tab_ui(bool update_plater_presets = false);
|
||||||
void load_config(const DynamicPrintConfig& config);
|
void load_config(const DynamicPrintConfig& config);
|
||||||
// Host/plugin API: set one option of this tab's currently edited preset from its serialized
|
|
||||||
// string value, then run the same committed-change path a field edit runs. `opt_key` must be a
|
|
||||||
// setting of this tab's preset type, so a key of another preset type (e.g. a printer setting
|
|
||||||
// passed to the process tab) is rejected instead of written silently. `value` is parsed by the
|
|
||||||
// strict config deserializer, which raises std::invalid_argument for an unknown key or a value
|
|
||||||
// the option cannot parse, and leaves the preset unchanged. The key-specific behaviour runs
|
|
||||||
// through its ConfigOptionsGroup::on_change_OG(): with the option's built field it uses the
|
|
||||||
// field's typed value, otherwise the owning group builds the value and dispatches the same call,
|
|
||||||
// so a not-yet-built field still gets it. A key the GUI edits through a custom widget instead
|
|
||||||
// takes the shared no-field commit path, Tab::load_key_value() in saved mode.
|
|
||||||
void apply_preset_config_value(const std::string& opt_key, const std::string& value);
|
|
||||||
|
|
||||||
// Host/plugin API: apply a batch of settings to this tab's currently edited preset as one
|
|
||||||
// transaction. Each pair is a setting of this tab's preset type and its serialized string value.
|
|
||||||
// The whole batch is parsed and validated before the edited preset is touched; each setting then
|
|
||||||
// runs the same committed-change path apply_preset_config_value() uses, so setting-specific
|
|
||||||
// validation, correction and on_change callbacks stay authoritative. Duplicate keys are rejected,
|
|
||||||
// because the transaction must not resolve an incidental write order, and the batch order is
|
|
||||||
// preserved. If applying the batch fails, the edited preset's config is restored to its pre-batch
|
|
||||||
// state and the state derived from it (compatibility marks, the preset selector, dependent tabs,
|
|
||||||
// the Plater and the printer agent) is recomputed, so the edited preset never keeps a subset of
|
|
||||||
// the staged settings.
|
|
||||||
//
|
|
||||||
// Only the edited preset is restored. A setting's on_change callback is a GUI method, not a pure
|
|
||||||
// function of the config, so effects outside the edited preset cannot be prepared or undone: the
|
|
||||||
// transaction does not reverse preset/slot selection made while resolving the target, a dialog
|
|
||||||
// decision the user already accepted, an undo snapshot, a dependent preset's rewritten config, or
|
|
||||||
// anything already sent to a device. Exact all-or-nothing across those is not achievable while the
|
|
||||||
// authoritative GUI callbacks run; this method guarantees the edited preset's own settings, not
|
|
||||||
// those external effects.
|
|
||||||
//
|
|
||||||
// Throws std::invalid_argument for an unknown key, a value the option cannot parse, or a duplicate
|
|
||||||
// key; std::runtime_error when the preset or main window is unavailable.
|
|
||||||
void apply_preset_config_values(const std::vector<std::pair<std::string, std::string>>& values);
|
|
||||||
virtual void reload_config();
|
virtual void reload_config();
|
||||||
void update_mode();
|
void update_mode();
|
||||||
void update_visibility();
|
void update_visibility();
|
||||||
@@ -541,12 +507,6 @@ protected:
|
|||||||
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
|
wxSizer* compatible_widget_create(wxWindow* parent, PresetDependencies &deps);
|
||||||
void compatible_widget_reload(PresetDependencies &deps);
|
void compatible_widget_reload(PresetDependencies &deps);
|
||||||
void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false);
|
void load_key_value(const std::string& opt_key, const boost::any& value, bool saved_value = false);
|
||||||
// Commit one option whose new value already sits in m_config through the same path a field edit
|
|
||||||
// runs (throwing field, otherwise the owning group's on_change_OG()). Reloads the option's page
|
|
||||||
// so a built field reports the committed value, but performs no tab-wide refresh. Returns true
|
|
||||||
// when a built field or a group owns the option, false when the GUI edits it through a custom
|
|
||||||
// widget with no group entry.
|
|
||||||
bool commit_preset_config_value(const std::string& opt_key);
|
|
||||||
|
|
||||||
//BBS: GUI refactor
|
//BBS: GUI refactor
|
||||||
// return true if cancelled
|
// return true if cancelled
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef __LINUX__
|
#ifdef __LINUX__
|
||||||
|
#include "LinuxDisplayBackend.hpp"
|
||||||
#include <wx/textfile.h>
|
#include <wx/textfile.h>
|
||||||
#include <wx/tokenzr.h>
|
#include <wx/tokenzr.h>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -201,7 +202,11 @@ TroubleshootDialog::TroubleshootDialog()
|
|||||||
return std::vector<wxString>{GetOStype()};
|
return std::vector<wxString>{GetOStype()};
|
||||||
return std::vector<wxString>{
|
return std::vector<wxString>{
|
||||||
GetOSinfo(),
|
GetOSinfo(),
|
||||||
|
#ifdef __LINUX__
|
||||||
|
GetPackageType() + GetAppDisplayBackend(),
|
||||||
|
#else
|
||||||
GetPackageType(),
|
GetPackageType(),
|
||||||
|
#endif
|
||||||
GetCPUinfo(),
|
GetCPUinfo(),
|
||||||
GetRAMinfo() + " RAM",
|
GetRAMinfo() + " RAM",
|
||||||
GetGPUinfo(),
|
GetGPUinfo(),
|
||||||
@@ -434,10 +439,15 @@ wxString TroubleshootDialog::GetTimestamp()
|
|||||||
|
|
||||||
wxString TroubleshootDialog::GetSysInfoAll()
|
wxString TroubleshootDialog::GetSysInfoAll()
|
||||||
{
|
{
|
||||||
|
wxString package = GetPackageType();
|
||||||
|
#ifdef __LINUX__
|
||||||
|
package += GetAppDisplayBackend();
|
||||||
|
#endif
|
||||||
|
|
||||||
wxString info;
|
wxString info;
|
||||||
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
|
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
|
||||||
+ "Build : " + wxString(build_commit_label) + "\n"
|
+ "Build : " + wxString(build_commit_label) + "\n"
|
||||||
+ "Package : " + GetPackageType() + "\n"
|
+ "Package : " + package + "\n"
|
||||||
+ "Platform : " + GetOSinfo() + "\n"
|
+ "Platform : " + GetOSinfo() + "\n"
|
||||||
+ "Processor : " + GetCPUinfo() + "\n"
|
+ "Processor : " + GetCPUinfo() + "\n"
|
||||||
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
|
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
|
||||||
@@ -751,6 +761,18 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
|
|||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wxString TroubleshootDialog::GetAppDisplayBackend()
|
||||||
|
{
|
||||||
|
// Display backend actually used by the app (GDK), not the session type
|
||||||
|
wxString display;
|
||||||
|
switch (get_linux_display_backend()) {
|
||||||
|
case LinuxDisplayBackend::X11: display = "X11"; break;
|
||||||
|
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
wxString TroubleshootDialog::GetPackageType()
|
wxString TroubleshootDialog::GetPackageType()
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ protected:
|
|||||||
#elif defined(__LINUX__)
|
#elif defined(__LINUX__)
|
||||||
wxString GetLinuxDistroName();
|
wxString GetLinuxDistroName();
|
||||||
wxString GetLinuxDisplayServer();
|
wxString GetLinuxDisplayServer();
|
||||||
|
wxString GetAppDisplayBackend();
|
||||||
#endif
|
#endif
|
||||||
wxString GetPackageType();
|
wxString GetPackageType();
|
||||||
wxString GetCPUinfo();
|
wxString GetCPUinfo();
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
|
|||||||
|
|
||||||
void ComboBox::ForceDropdownOpen()
|
void ComboBox::ForceDropdownOpen()
|
||||||
{
|
{
|
||||||
if (!IsEnabled())
|
if (!IsEnabled() || items.empty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!drop_down) {
|
if (!drop_down) {
|
||||||
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
|
|||||||
SetFocus();
|
SetFocus();
|
||||||
if (drop_down) {
|
if (drop_down) {
|
||||||
drop.Hide();
|
drop.Hide();
|
||||||
} else if (drop.HasDismissLongTime()) {
|
} else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
|
||||||
drop.autoPosition();
|
drop.autoPosition();
|
||||||
drop_down = true;
|
drop_down = true;
|
||||||
drop.Popup(&drop);
|
drop.Popup(&drop);
|
||||||
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
|
|||||||
case WXK_SPACE:
|
case WXK_SPACE:
|
||||||
if (drop_down) {
|
if (drop_down) {
|
||||||
drop.DismissAndNotify();
|
drop.DismissAndNotify();
|
||||||
} else if (drop.HasDismissLongTime()) {
|
} else if (!items.empty() && drop.HasDismissLongTime()) {
|
||||||
drop.autoPosition();
|
drop.autoPosition();
|
||||||
drop_down = true;
|
drop_down = true;
|
||||||
drop.Popup();
|
drop.Popup();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "bambu_networking.hpp"
|
#include "bambu_networking.hpp"
|
||||||
#include "libslic3r/Preset.hpp"
|
#include "libslic3r/Preset.hpp"
|
||||||
#include "libslic3r/PresetBundle.hpp"
|
#include "libslic3r/PresetBundle.hpp"
|
||||||
|
#include "libslic3r/Utils.hpp"
|
||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
#include "slic3r/GUI/I18N.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
#include "slic3r/GUI/DeviceManager.hpp"
|
#include "slic3r/GUI/DeviceManager.hpp"
|
||||||
@@ -19,14 +20,19 @@
|
|||||||
#include <boost/algorithm/string/trim.hpp>
|
#include <boost/algorithm/string/trim.hpp>
|
||||||
#include <boost/asio/buffer.hpp>
|
#include <boost/asio/buffer.hpp>
|
||||||
#include <boost/asio/connect.hpp>
|
#include <boost/asio/connect.hpp>
|
||||||
|
#include <boost/asio/error.hpp>
|
||||||
#include <boost/asio/io_context.hpp>
|
#include <boost/asio/io_context.hpp>
|
||||||
#include <boost/asio/ip/tcp.hpp>
|
#include <boost/asio/ip/tcp.hpp>
|
||||||
|
#include <boost/asio/post.hpp>
|
||||||
|
#include <boost/asio/steady_timer.hpp>
|
||||||
#include <boost/beast/core.hpp>
|
#include <boost/beast/core.hpp>
|
||||||
#include <boost/beast/core/tcp_stream.hpp>
|
#include <boost/beast/core/tcp_stream.hpp>
|
||||||
#include <boost/beast/http/field.hpp>
|
#include <boost/beast/http/field.hpp>
|
||||||
#include <boost/beast/core/flat_buffer.hpp>
|
#include <boost/beast/core/flat_buffer.hpp>
|
||||||
#include <boost/beast/core/error.hpp>
|
#include <boost/beast/core/error.hpp>
|
||||||
#include <boost/beast/core/buffers_to_string.hpp>
|
#include <boost/beast/core/buffers_to_string.hpp>
|
||||||
|
#include <boost/beast/core/stream_traits.hpp>
|
||||||
|
#include <boost/beast/core/string_type.hpp>
|
||||||
#include <boost/beast/websocket.hpp>
|
#include <boost/beast/websocket.hpp>
|
||||||
#include <boost/beast/websocket/stream.hpp>
|
#include <boost/beast/websocket/stream.hpp>
|
||||||
#include <boost/beast/websocket/stream_base.hpp>
|
#include <boost/beast/websocket/stream_base.hpp>
|
||||||
@@ -37,6 +43,7 @@
|
|||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -120,6 +127,31 @@ std::string map_moonraker_state(std::string state)
|
|||||||
return "IDLE";
|
return "IDLE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
|
||||||
|
template<class Initiate>
|
||||||
|
beast::error_code run_until_done(net::io_context& ioc, const std::atomic<bool>& stop, Initiate&& initiate)
|
||||||
|
{
|
||||||
|
// An abort that runs before the operation starts finds nothing to cancel
|
||||||
|
if (stop.load()) {
|
||||||
|
return net::error::operation_aborted;
|
||||||
|
}
|
||||||
|
beast::error_code result;
|
||||||
|
bool done = false;
|
||||||
|
initiate([&](beast::error_code ec, auto&&...) {
|
||||||
|
result = ec;
|
||||||
|
done = true;
|
||||||
|
});
|
||||||
|
ioc.restart();
|
||||||
|
while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
|
||||||
|
return done ? result : beast::error_code{net::error::operation_aborted};
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t steady_now_ms()
|
||||||
|
{
|
||||||
|
return static_cast<uint64_t>(
|
||||||
|
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
@@ -1298,6 +1330,8 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
|
|||||||
}
|
}
|
||||||
http.timeout_connect(5)
|
http.timeout_connect(5)
|
||||||
.timeout_max(10)
|
.timeout_max(10)
|
||||||
|
// Gives up once the status stream, the only caller, stops
|
||||||
|
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
|
||||||
.on_complete([&](std::string body, unsigned status) {
|
.on_complete([&](std::string body, unsigned status) {
|
||||||
if (status == 200) {
|
if (status == 200) {
|
||||||
response_body = body;
|
response_body = body;
|
||||||
@@ -1468,19 +1502,47 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||||
ws_stop.store(false);
|
ws_stop.store(false);
|
||||||
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); });
|
ws_thread = std::thread(
|
||||||
|
[this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
|
||||||
}
|
}
|
||||||
|
|
||||||
void MoonrakerPrinterAgent::stop_status_stream()
|
void MoonrakerPrinterAgent::stop_status_stream()
|
||||||
{
|
{
|
||||||
ws_stop.store(true);
|
std::thread stopped;
|
||||||
if (ws_thread.joinable()) {
|
{
|
||||||
ws_thread.join();
|
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||||
|
ws_stop.store(true);
|
||||||
|
if (ws_abort) {
|
||||||
|
ws_abort();
|
||||||
|
}
|
||||||
|
// A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
|
||||||
|
if (ws_thread.get_id() != std::this_thread::get_id()) {
|
||||||
|
stopped = std::move(ws_thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stopped.joinable()) {
|
||||||
|
stopped.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key)
|
void MoonrakerPrinterAgent::join_stopped_status_stream()
|
||||||
|
{
|
||||||
|
std::thread stopped;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||||
|
// A running stream belongs to a newer connection, so only one that is already stopping is joined
|
||||||
|
if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
|
||||||
|
stopped = std::move(ws_thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stopped.joinable()) {
|
||||||
|
stopped.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
|
||||||
{
|
{
|
||||||
WsEndpoint endpoint;
|
WsEndpoint endpoint;
|
||||||
if (!parse_ws_endpoint(base_url, endpoint)) {
|
if (!parse_ws_endpoint(base_url, endpoint)) {
|
||||||
@@ -1492,25 +1554,108 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
net::io_context ioc;
|
||||||
|
net::steady_timer backoff{ioc};
|
||||||
|
std::function<void()> abort_connection; // Set while a connection attempt runs
|
||||||
|
const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
|
||||||
|
|
||||||
|
// Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||||
|
if (ws_stop.load()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws_abort = [&] {
|
||||||
|
net::post(ioc, [&] {
|
||||||
|
backoff.cancel();
|
||||||
|
if (abort_connection) {
|
||||||
|
abort_connection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ScopeGuard clear_abort([this] {
|
||||||
|
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||||
|
ws_abort = nullptr;
|
||||||
|
});
|
||||||
|
|
||||||
// Reconnection logic
|
// Reconnection logic
|
||||||
ws_reconnect_requested.store(false); // Reset reconnect flag
|
ws_reconnect_requested.store(false); // Reset reconnect flag
|
||||||
int retry_count = 0;
|
int retry_count = 0;
|
||||||
const int max_retries = 10;
|
|
||||||
const int base_delay_ms = 1000;
|
|
||||||
|
|
||||||
while (!ws_stop.load() && retry_count < max_retries) {
|
while (!ws_stop.load()) {
|
||||||
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
|
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
|
||||||
|
|
||||||
|
tcp::resolver resolver{ioc};
|
||||||
|
websocket::stream<beast::tcp_stream> ws{ioc};
|
||||||
|
beast::tcp_stream& stream = beast::get_lowest_layer(ws);
|
||||||
|
net::steady_timer close_deadline{ioc};
|
||||||
|
bool closing = false;
|
||||||
|
bool deadline_pending = false;
|
||||||
|
auto last_message = std::chrono::steady_clock::now();
|
||||||
|
// Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
|
||||||
|
const auto close_politely = [&](websocket::close_code code) {
|
||||||
|
if (!ws.is_open() || closing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closing = true;
|
||||||
|
deadline_pending = true;
|
||||||
|
close_deadline.expires_after(timings.close);
|
||||||
|
close_deadline.async_wait([&](beast::error_code ec) {
|
||||||
|
deadline_pending = false;
|
||||||
|
if (!ec) {
|
||||||
|
beast::error_code ignored;
|
||||||
|
stream.socket().close(ignored);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.async_close(code, [&](beast::error_code) {
|
||||||
|
closing = false;
|
||||||
|
close_deadline.cancel();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
abort_connection = [&] {
|
||||||
|
// Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
|
||||||
|
if (closing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ws.is_open()) {
|
||||||
|
close_politely(websocket::close_code::going_away);
|
||||||
|
} else {
|
||||||
|
beast::error_code ignored;
|
||||||
|
stream.socket().close(ignored);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
net::io_context ioc;
|
beast::error_code connect_ec = net::error::host_not_found;
|
||||||
tcp::resolver resolver{ioc};
|
for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
|
||||||
beast::tcp_stream stream{ioc};
|
beast::error_code ignored;
|
||||||
|
stream.socket().close(ignored); // A failed attempt leaves the socket open
|
||||||
|
stream.expires_after(std::chrono::seconds(5));
|
||||||
|
connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
|
||||||
|
if (!connect_ec || ws_stop.load()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connect_ec) {
|
||||||
|
throw beast::system_error{connect_ec};
|
||||||
|
}
|
||||||
|
// Clears the connect deadline, which would otherwise close the socket 5 s after connecting
|
||||||
|
stream.expires_never();
|
||||||
|
|
||||||
stream.expires_after(std::chrono::seconds(10));
|
websocket::stream_base::timeout timeouts{};
|
||||||
auto const results = resolver.resolve(endpoint.host, endpoint.port);
|
timeouts.handshake_timeout = timings.handshake;
|
||||||
stream.connect(results);
|
timeouts.idle_timeout = timings.idle;
|
||||||
|
timeouts.keep_alive_pings = true;
|
||||||
websocket::stream<beast::tcp_stream> ws{std::move(stream)};
|
ws.set_option(timeouts);
|
||||||
|
// Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
|
||||||
|
ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
|
||||||
|
if (kind == websocket::frame_type::pong && !closing &&
|
||||||
|
std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
|
||||||
|
send_heartbeat_if_due(dev_id, timings.heartbeat);
|
||||||
|
}
|
||||||
|
});
|
||||||
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
|
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
|
||||||
req.set(http::field::user_agent, "OrcaSlicer");
|
req.set(http::field::user_agent, "OrcaSlicer");
|
||||||
if (!api_key.empty()) {
|
if (!api_key.empty()) {
|
||||||
@@ -1522,7 +1667,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
if (!endpoint.port.empty() && endpoint.port != "80") {
|
if (!endpoint.port.empty() && endpoint.port != "80") {
|
||||||
host_header += ":" + endpoint.port;
|
host_header += ":" + endpoint.port;
|
||||||
}
|
}
|
||||||
ws.handshake(host_header, endpoint.target);
|
if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
|
||||||
|
throw beast::system_error{ec};
|
||||||
|
}
|
||||||
ws.text(true);
|
ws.text(true);
|
||||||
|
|
||||||
// Send client identification
|
// Send client identification
|
||||||
@@ -1534,7 +1681,10 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
identify["params"]["type"] = "agent";
|
identify["params"]["type"] = "agent";
|
||||||
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
|
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
|
||||||
identify["id"] = 0;
|
identify["id"] = 0;
|
||||||
ws.write(net::buffer(identify.dump()));
|
const std::string identify_payload = identify.dump();
|
||||||
|
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
|
||||||
|
throw beast::system_error{ec};
|
||||||
|
}
|
||||||
|
|
||||||
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
|
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
|
||||||
std::set<std::string> available_objects;
|
std::set<std::string> available_objects;
|
||||||
@@ -1586,28 +1736,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
}
|
}
|
||||||
subscribe["params"]["objects"] = std::move(objects);
|
subscribe["params"]["objects"] = std::move(objects);
|
||||||
subscribe["id"] = 1;
|
subscribe["id"] = 1;
|
||||||
ws.write(net::buffer(subscribe.dump()));
|
const std::string subscribe_payload = subscribe.dump();
|
||||||
|
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
|
||||||
|
throw beast::system_error{ec};
|
||||||
|
}
|
||||||
|
|
||||||
// Read loop
|
// Read loop
|
||||||
while (!ws_stop.load()) {
|
while (!ws_stop.load()) {
|
||||||
ws.next_layer().expires_after(std::chrono::seconds(2));
|
|
||||||
beast::flat_buffer buffer;
|
beast::flat_buffer buffer;
|
||||||
beast::error_code ec;
|
const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
|
||||||
ws.read(buffer, ec);
|
if (ws_stop.load()) {
|
||||||
if (ec == beast::error::timeout) {
|
break;
|
||||||
const auto now_ms = static_cast<uint64_t>(
|
|
||||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
|
||||||
const auto last_ms = ws_last_emit_ms.load();
|
|
||||||
if (last_ms == 0 || now_ms - last_ms >= 10000) {
|
|
||||||
nlohmann::json message;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
|
|
||||||
message = build_print_payload_locked();
|
|
||||||
}
|
|
||||||
dispatch_message(dev_id, message.dump());
|
|
||||||
ws_last_emit_ms.store(now_ms);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (ec == websocket::error::closed) {
|
if (ec == websocket::error::closed) {
|
||||||
connection_lost = true;
|
connection_lost = true;
|
||||||
@@ -1618,6 +1757,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
connection_lost = true;
|
connection_lost = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
last_message = std::chrono::steady_clock::now();
|
||||||
|
// A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
|
||||||
|
retry_count = 0;
|
||||||
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
|
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
|
||||||
// Check if handle_ws_message triggered reconnection request
|
// Check if handle_ws_message triggered reconnection request
|
||||||
if (ws_reconnect_requested.exchange(false)) {
|
if (ws_reconnect_requested.exchange(false)) {
|
||||||
@@ -1625,20 +1767,18 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
beast::error_code ec;
|
|
||||||
ws.close(websocket::close_code::normal, ec);
|
|
||||||
|
|
||||||
// Only reset retry count on clean shutdown (not connection_lost)
|
|
||||||
if (!connection_lost && !ws_stop.load()) {
|
|
||||||
retry_count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
if (!ws_stop.load()) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
||||||
|
}
|
||||||
connection_lost = true;
|
connection_lost = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
|
||||||
|
ioc.restart();
|
||||||
|
// Both handlers refer to this connection, so neither may be left queued for the next one
|
||||||
|
while ((closing || deadline_pending) && ioc.run_one() != 0) {}
|
||||||
|
|
||||||
// Exit loop on clean shutdown
|
// Exit loop on clean shutdown
|
||||||
if (!connection_lost) {
|
if (!connection_lost) {
|
||||||
break;
|
break;
|
||||||
@@ -1649,17 +1789,13 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exponential backoff before reconnection
|
// Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
|
||||||
int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5));
|
const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
|
||||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")";
|
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
|
backoff.expires_after(delay);
|
||||||
|
run([&](auto&& handler) { backoff.async_wait(handler); });
|
||||||
retry_count++;
|
retry_count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (retry_count >= max_retries) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
|
|
||||||
dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
|
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
|
||||||
@@ -1733,8 +1869,7 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
const auto now_ms = static_cast<uint64_t>(
|
const auto now_ms = steady_now_ms();
|
||||||
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
|
||||||
const auto last_dispatch_ms = ws_last_dispatch_ms.load();
|
const auto last_dispatch_ms = ws_last_dispatch_ms.load();
|
||||||
|
|
||||||
// Dispatch if: critical change OR throttle interval elapsed
|
// Dispatch if: critical change OR throttle interval elapsed
|
||||||
@@ -1755,6 +1890,24 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
|
||||||
|
{
|
||||||
|
const auto now_ms = steady_now_ms();
|
||||||
|
const auto last_ms = ws_last_emit_ms.load();
|
||||||
|
// Re-sends only a status the printer has reported at least once
|
||||||
|
if (last_ms == 0 || now_ms - last_ms < static_cast<uint64_t>(interval.count())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json message;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
|
||||||
|
message = build_print_payload_locked();
|
||||||
|
}
|
||||||
|
dispatch_message(dev_id, message.dump());
|
||||||
|
ws_last_emit_ms.store(now_ms);
|
||||||
|
}
|
||||||
|
|
||||||
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
|
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
|
||||||
{
|
{
|
||||||
if (!updates.is_object()) {
|
if (!updates.is_object()) {
|
||||||
@@ -2139,8 +2292,15 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
|
|||||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
|
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start WebSocket status stream
|
join_stopped_status_stream();
|
||||||
start_status_stream(dev_id, base_url, api_key);
|
// Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
|
||||||
|
if (is_stale()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
start_status_stream(dev_id, base_url, api_key);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Success!
|
// Success!
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "bambu_networking.hpp"
|
#include "bambu_networking.hpp"
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -85,6 +86,16 @@ protected:
|
|||||||
bool use_ssl = false;
|
bool use_ssl = false;
|
||||||
} device_info;
|
} device_info;
|
||||||
|
|
||||||
|
// Copied when the stream starts, so changes apply to the next start_status_stream()
|
||||||
|
struct StatusStreamTimings
|
||||||
|
{
|
||||||
|
std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
|
||||||
|
std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
|
||||||
|
std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
|
||||||
|
std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
|
||||||
|
std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
|
||||||
|
} ws_timings;
|
||||||
|
|
||||||
// Tray data for AMS payload building
|
// Tray data for AMS payload building
|
||||||
struct AmsTrayData {
|
struct AmsTrayData {
|
||||||
int slot_index = 0; // 0-based slot index
|
int slot_index = 0; // 0-based slot index
|
||||||
@@ -102,6 +113,8 @@ protected:
|
|||||||
// Methods that derived classes may need to override or access
|
// Methods that derived classes may need to override or access
|
||||||
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
|
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
|
||||||
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
|
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
|
||||||
|
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
||||||
|
void stop_status_stream();
|
||||||
|
|
||||||
// State access for derived classes
|
// State access for derived classes
|
||||||
mutable std::recursive_mutex state_mutex;
|
mutable std::recursive_mutex state_mutex;
|
||||||
@@ -133,10 +146,11 @@ private:
|
|||||||
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
|
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
|
||||||
void dispatch_printer_connected(const std::string& dev_id);
|
void dispatch_printer_connected(const std::string& dev_id);
|
||||||
void dispatch_message(const std::string& dev_id, const std::string& payload);
|
void dispatch_message(const std::string& dev_id, const std::string& payload);
|
||||||
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
|
||||||
void stop_status_stream();
|
void join_stopped_status_stream();
|
||||||
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
|
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
|
||||||
void handle_ws_message(const std::string& dev_id, const std::string& payload);
|
void handle_ws_message(const std::string& dev_id, const std::string& payload);
|
||||||
|
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
|
||||||
void update_status_cache(const nlohmann::json& updates);
|
void update_status_cache(const nlohmann::json& updates);
|
||||||
nlohmann::json build_print_payload_locked() const;
|
nlohmann::json build_print_payload_locked() const;
|
||||||
|
|
||||||
@@ -196,6 +210,8 @@ private:
|
|||||||
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
|
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
|
||||||
std::atomic<uint64_t> ws_last_emit_ms{0};
|
std::atomic<uint64_t> ws_last_emit_ms{0};
|
||||||
std::thread ws_thread;
|
std::thread ws_thread;
|
||||||
|
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
|
||||||
|
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
|
||||||
|
|
||||||
// Throttling configuration for WebSocket updates
|
// Throttling configuration for WebSocket updates
|
||||||
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
|
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
|
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
|
||||||
#include "libslic3r_version.h"
|
#include "libslic3r_version.h"
|
||||||
|
|
||||||
|
#include <boost/algorithm/string/case_conv.hpp>
|
||||||
#include <boost/algorithm/string/predicate.hpp>
|
#include <boost/algorithm/string/predicate.hpp>
|
||||||
|
#include <boost/algorithm/string/trim.hpp>
|
||||||
|
#include <boost/asio/ip/address.hpp>
|
||||||
#include <boost/filesystem/path.hpp>
|
#include <boost/filesystem/path.hpp>
|
||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
#include <boost/system/error_code.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -146,6 +150,205 @@ static bool is_fs_category(AuditEventCategory category)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP and raw-socket events are governed by the same network permission: a declared host may be
|
||||||
|
// reached through either layer, and the layers nest for one plugin action (urllib -> http.client
|
||||||
|
// -> socket).
|
||||||
|
static bool is_network_category(AuditEventCategory category)
|
||||||
|
{
|
||||||
|
return category == AuditEventCategory::Http || category == AuditEventCategory::Socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The only network events whose target can be a resolved address instead of the host the plugin
|
||||||
|
// asked for. They may fall back to a network-approved call site; every other network event must
|
||||||
|
// name a host or URL the hook can match directly.
|
||||||
|
static bool is_resolved_address_event(const std::string& event_name)
|
||||||
|
{
|
||||||
|
return event_name == "socket.connect" || event_name == "socket.sendto";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Network permission identity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static std::string ascii_lower(std::string value)
|
||||||
|
{
|
||||||
|
boost::algorithm::to_lower(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string trim_ascii_space(std::string value)
|
||||||
|
{
|
||||||
|
boost::algorithm::trim(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical storage keeps IPv6 literals bracketed (https://[::1]), while a socket address
|
||||||
|
// exposes the bare literal (::1). Compare them without the brackets.
|
||||||
|
static std::string without_ipv6_brackets(std::string host)
|
||||||
|
{
|
||||||
|
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
|
||||||
|
host = host.substr(1, host.size() - 2);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the host part of a URL authority, dropping userinfo and port. IPv6 literals keep their
|
||||||
|
// brackets so that a URL and a bare address compare consistently.
|
||||||
|
static std::string host_from_authority(std::string authority)
|
||||||
|
{
|
||||||
|
const size_t at = authority.rfind('@');
|
||||||
|
if (at != std::string::npos)
|
||||||
|
authority.erase(0, at + 1);
|
||||||
|
|
||||||
|
if (authority.empty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
if (authority.front() == '[') {
|
||||||
|
const size_t close = authority.find(']');
|
||||||
|
if (close == std::string::npos)
|
||||||
|
return {};
|
||||||
|
authority.erase(close + 1);
|
||||||
|
return ascii_lower(authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t colon = authority.find(':');
|
||||||
|
if (colon != std::string::npos)
|
||||||
|
authority.erase(colon);
|
||||||
|
|
||||||
|
if (authority.find_first_of(" \t\r\n/?#@") != std::string::npos)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return ascii_lower(authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts a host from a target that is not a URL: "host", "host:443", "[::1]:443", "('host',
|
||||||
|
// 443)" (the form older releases persisted for socket address events), or "'host'".
|
||||||
|
static std::string network_host_from_target(const std::string& target)
|
||||||
|
{
|
||||||
|
std::string value = trim_ascii_space(target);
|
||||||
|
if (value.empty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
if (value.front() == '(') {
|
||||||
|
// Legacy persisted address tuple: ("host", 443) or ("host", 443, 0, 0).
|
||||||
|
const size_t quote = value.find_first_of("'\"");
|
||||||
|
if (quote != std::string::npos) {
|
||||||
|
const char delimiter = value[quote];
|
||||||
|
const size_t end = value.find(delimiter, quote + 1);
|
||||||
|
if (end == std::string::npos)
|
||||||
|
return {};
|
||||||
|
value = value.substr(quote + 1, end - quote - 1);
|
||||||
|
} else {
|
||||||
|
value = value.substr(1);
|
||||||
|
const size_t comma = value.find(',');
|
||||||
|
if (comma != std::string::npos)
|
||||||
|
value.erase(comma);
|
||||||
|
}
|
||||||
|
} else if (value.front() == '[') {
|
||||||
|
const size_t close = value.find(']');
|
||||||
|
if (close == std::string::npos)
|
||||||
|
return {};
|
||||||
|
value.erase(close + 1);
|
||||||
|
} else {
|
||||||
|
// Strip a ":port" suffix, but leave a bare IPv6 literal ("::1") intact.
|
||||||
|
const size_t colon = value.find(':');
|
||||||
|
if (colon != std::string::npos && value.find(':', colon + 1) == std::string::npos)
|
||||||
|
value.erase(colon);
|
||||||
|
}
|
||||||
|
|
||||||
|
value = trim_ascii_space(value);
|
||||||
|
if (value.empty() || value.find_first_of(" \t\r\n/?#@") != std::string::npos)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return ascii_lower(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string PluginAuditManager::normalize_network_url(const std::string& url)
|
||||||
|
{
|
||||||
|
const size_t scheme_end = url.find("://");
|
||||||
|
if (scheme_end == std::string::npos || scheme_end == 0)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const std::string scheme = ascii_lower(url.substr(0, scheme_end));
|
||||||
|
if (scheme != "http" && scheme != "https")
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const size_t authority_start = scheme_end + 3;
|
||||||
|
const size_t authority_end = url.find_first_of("/?#", authority_start);
|
||||||
|
const std::string authority = url.substr(authority_start, authority_end == std::string::npos
|
||||||
|
? std::string::npos
|
||||||
|
: authority_end - authority_start);
|
||||||
|
|
||||||
|
const std::string host = host_from_authority(authority);
|
||||||
|
if (host.empty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return scheme + "://" + host;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::network_permission_matches(const std::vector<std::string>& granted,
|
||||||
|
const std::string& target)
|
||||||
|
{
|
||||||
|
if (target.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const bool target_is_url = target.find("://") != std::string::npos;
|
||||||
|
const std::string target_url = normalize_network_url(target);
|
||||||
|
if (target_is_url && target_url.empty()) {
|
||||||
|
// A non-http(s) URL has no network identity we can generalize; only an exact grant
|
||||||
|
// recorded by an earlier approval stays valid, so an unrelated scheme never matches.
|
||||||
|
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string target_host =
|
||||||
|
target_is_url ? std::string{} : without_ipv6_brackets(network_host_from_target(target));
|
||||||
|
if (!target_is_url && target_host.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (const std::string& entry : granted) {
|
||||||
|
// Preserve an exact grant recorded by an older release, e.g. a full URL with a query.
|
||||||
|
if (entry == target)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const std::string entry_url = normalize_network_url(entry);
|
||||||
|
if (target_is_url) {
|
||||||
|
if (entry_url == target_url)
|
||||||
|
return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry_url.empty()) {
|
||||||
|
// Bare hosts carry no scheme: match the host part of a scheme://host grant.
|
||||||
|
const std::string entry_host = entry_url.substr(entry_url.find("://") + 3);
|
||||||
|
if (without_ipv6_brackets(entry_host) == target_host)
|
||||||
|
return true;
|
||||||
|
} else if (without_ipv6_brackets(network_host_from_target(entry)) == target_host) {
|
||||||
|
// Legacy persisted address tuples, e.g. "('host', 443)".
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::is_ip_address_target(const std::string& target)
|
||||||
|
{
|
||||||
|
if (target.empty() || target.find("://") != std::string::npos)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
std::string host = target;
|
||||||
|
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
|
||||||
|
host = host.substr(1, host.size() - 2);
|
||||||
|
|
||||||
|
// Drop an IPv6 zone identifier before parsing; it is not part of the address literal.
|
||||||
|
const size_t zone = host.find('%');
|
||||||
|
if (zone != std::string::npos)
|
||||||
|
host.erase(zone);
|
||||||
|
|
||||||
|
boost::system::error_code ec;
|
||||||
|
boost::asio::ip::make_address(host, ec);
|
||||||
|
return !ec;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Path safety
|
// Path safety
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -471,6 +674,39 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
|
|||||||
return check_path_access(boost::filesystem::path(path_str), is_write);
|
return check_path_access(boost::filesystem::path(path_str), is_write);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Builds and shows the modal confirmation. Must run on the GUI thread.
|
||||||
|
bool show_permission_dialog(const wxString& message, const wxString& title)
|
||||||
|
{
|
||||||
|
wxMessageDialog dialog(nullptr, message, title, wxYES_NO | wxICON_WARNING);
|
||||||
|
return dialog.ShowModal() == wxID_YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows a permission prompt on the GUI thread and waits for the answer. The caller may be the
|
||||||
|
// plugin-load worker, so off the main thread the dialog is marshaled with CallAfter.
|
||||||
|
bool ask_permission(const wxString& message)
|
||||||
|
{
|
||||||
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (wxIsMainThread())
|
||||||
|
return show_permission_dialog(message, _L("Plugin permissions"));
|
||||||
|
|
||||||
|
auto result = std::make_shared<std::promise<bool>>();
|
||||||
|
auto future = result->get_future();
|
||||||
|
GUI::wxGetApp().CallAfter([result, message]() {
|
||||||
|
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) {
|
||||||
|
result->set_value(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result->set_value(show_permission_dialog(message, _L("Plugin permissions")));
|
||||||
|
});
|
||||||
|
return future.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
|
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
|
||||||
const std::vector<std::string>& paths)
|
const std::vector<std::string>& paths)
|
||||||
{
|
{
|
||||||
@@ -492,60 +728,80 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
|
|||||||
if (missing.empty())
|
if (missing.empty())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
wxString requested_paths;
|
||||||
|
for (const std::string& path : missing)
|
||||||
|
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
||||||
|
|
||||||
|
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
||||||
|
wxString::FromUTF8(descriptor.name.c_str()), requested_paths)))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
auto show_dialog = [&descriptor, &missing]() {
|
seed_install_metadata(state, descriptor);
|
||||||
wxString requested_paths;
|
|
||||||
for (const std::string& path : missing)
|
|
||||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
|
||||||
|
|
||||||
wxMessageDialog dialog(
|
|
||||||
nullptr,
|
|
||||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
|
||||||
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
|
|
||||||
_L("Plugin permissions"),
|
|
||||||
wxYES_NO | wxICON_WARNING);
|
|
||||||
return dialog.ShowModal() == wxID_YES;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool granted = false;
|
|
||||||
if (wxIsMainThread()) {
|
|
||||||
granted = show_dialog();
|
|
||||||
} else {
|
|
||||||
auto result = std::make_shared<std::promise<bool>>();
|
|
||||||
auto future = result->get_future();
|
|
||||||
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
|
|
||||||
wxString requested_paths;
|
|
||||||
for (const std::string& path : missing)
|
|
||||||
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
|
|
||||||
|
|
||||||
wxMessageDialog dialog(
|
|
||||||
nullptr,
|
|
||||||
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
|
|
||||||
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
|
|
||||||
_L("Plugin permissions"),
|
|
||||||
wxYES_NO | wxICON_WARNING);
|
|
||||||
result->set_value(dialog.ShowModal() == wxID_YES);
|
|
||||||
});
|
|
||||||
granted = future.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!granted)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (state.plugin_name.empty()) {
|
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
|
||||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
|
||||||
state.plugin_name = descriptor.name;
|
|
||||||
state.cloud_uuid = descriptor.cloud_uuid();
|
|
||||||
state.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
|
||||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::request_network_permissions(const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& urls)
|
||||||
|
{
|
||||||
|
if (plugin_key.empty() || urls.empty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
PluginDescriptor descriptor;
|
||||||
|
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Reduce every request to scheme://host before comparing or persisting it, so a URL with a
|
||||||
|
// path or query cannot create a second grant for the same host.
|
||||||
|
std::vector<std::string> requested;
|
||||||
|
for (const std::string& url : urls) {
|
||||||
|
const std::string normalized = normalize_network_url(url);
|
||||||
|
if (normalized.empty()) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Invalid network permission request for plugin " << plugin_key << ": " << url;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (std::find(requested.begin(), requested.end(), normalized) == requested.end())
|
||||||
|
requested.push_back(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginInstallState state;
|
||||||
|
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
|
|
||||||
|
std::vector<std::string> missing;
|
||||||
|
for (const std::string& entry : requested) {
|
||||||
|
if (!network_permission_matches(state.permissions.network_http, entry) &&
|
||||||
|
!network_permission_matches(state.permissions.network_socket, entry))
|
||||||
|
missing.push_back(entry);
|
||||||
|
}
|
||||||
|
if (missing.empty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
wxString requested_hosts;
|
||||||
|
for (const std::string& entry : missing)
|
||||||
|
requested_hosts += wxString::FromUTF8(entry.c_str()) + "\n";
|
||||||
|
|
||||||
|
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests network access to:\n%s"),
|
||||||
|
wxString::FromUTF8(descriptor.name.c_str()), requested_hosts)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
seed_install_metadata(state, descriptor);
|
||||||
|
|
||||||
|
// A declared host is reachable through both HTTP and raw socket events. Recording the grant in
|
||||||
|
// both existing lists keeps the sidecar self-describing and means either event layer matches
|
||||||
|
// it without relying on the other list.
|
||||||
|
for (const std::string& entry : missing) {
|
||||||
|
if (std::find(state.permissions.network_http.begin(), state.permissions.network_http.end(), entry) ==
|
||||||
|
state.permissions.network_http.end())
|
||||||
|
state.permissions.network_http.push_back(entry);
|
||||||
|
if (std::find(state.permissions.network_socket.begin(), state.permissions.network_socket.end(), entry) ==
|
||||||
|
state.permissions.network_socket.end())
|
||||||
|
state.permissions.network_socket.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
|
}
|
||||||
|
|
||||||
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
||||||
{
|
{
|
||||||
m_last_violation = violation;
|
m_last_violation = violation;
|
||||||
@@ -577,13 +833,38 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
|
|||||||
|
|
||||||
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
|
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
|
||||||
const std::vector<std::string>& call_site_ids) const
|
const std::vector<std::string>& call_site_ids) const
|
||||||
|
{
|
||||||
|
return has_call_site(m_approved_call_sites, plugin_key, call_site_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids)
|
||||||
|
{
|
||||||
|
record_call_sites(m_approved_call_sites, plugin_key, call_site_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::has_approved_network_ancestor(const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids) const
|
||||||
|
{
|
||||||
|
return has_call_site(m_approved_network_call_sites, plugin_key, call_site_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginAuditManager::record_approved_network_call_sites(const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids)
|
||||||
|
{
|
||||||
|
record_call_sites(m_approved_network_call_sites, plugin_key, call_site_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PluginAuditManager::has_call_site(const CallSiteRegistry& registry,
|
||||||
|
const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids) const
|
||||||
{
|
{
|
||||||
if (plugin_key.empty() || call_site_ids.empty())
|
if (plugin_key.empty() || call_site_ids.empty())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
auto it = m_approved_call_sites.find(plugin_key);
|
auto it = registry.find(plugin_key);
|
||||||
if (it == m_approved_call_sites.end())
|
if (it == registry.end())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
for (const auto& id : call_site_ids)
|
for (const auto& id : call_site_ids)
|
||||||
@@ -592,14 +873,15 @@ bool PluginAuditManager::has_approved_ancestor(const std::string& p
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
|
void PluginAuditManager::record_call_sites(CallSiteRegistry& registry,
|
||||||
const std::vector<std::string>& call_site_ids)
|
const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids)
|
||||||
{
|
{
|
||||||
if (plugin_key.empty() || call_site_ids.empty())
|
if (plugin_key.empty() || call_site_ids.empty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
auto& approved = m_approved_call_sites[plugin_key];
|
auto& approved = registry[plugin_key];
|
||||||
approved.insert(call_site_ids.begin(), call_site_ids.end());
|
approved.insert(call_site_ids.begin(), call_site_ids.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +898,39 @@ PyObject* tuple_item(PyObject* args, Py_ssize_t index)
|
|||||||
return PyTuple_GET_ITEM(args, index);
|
return PyTuple_GET_ITEM(args, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string python_unicode(PyObject* object)
|
||||||
|
{
|
||||||
|
if (!object || !PyUnicode_Check(object))
|
||||||
|
return {};
|
||||||
|
const char* text = PyUnicode_AsUTF8(object);
|
||||||
|
if (!text) {
|
||||||
|
PyErr_Clear();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a str/bytes leaf to text, clearing a failed conversion. Returns false when `object` is
|
||||||
|
// neither str nor bytes, so callers can apply their own fallback.
|
||||||
|
bool python_text(PyObject* object, std::string& out)
|
||||||
|
{
|
||||||
|
if (PyUnicode_Check(object)) {
|
||||||
|
out = python_unicode(object);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (PyBytes_Check(object)) {
|
||||||
|
const char* value = PyBytes_AsString(object);
|
||||||
|
if (!value) {
|
||||||
|
PyErr_Clear();
|
||||||
|
out.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
out = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::string python_path(PyObject* object)
|
std::string python_path(PyObject* object)
|
||||||
{
|
{
|
||||||
if (!object)
|
if (!object)
|
||||||
@@ -627,26 +942,12 @@ std::string python_path(PyObject* object)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
|
std::string result;
|
||||||
std::string result = path ? path : "";
|
python_text(path_object, result);
|
||||||
Py_DECREF(path_object);
|
Py_DECREF(path_object);
|
||||||
if (!path)
|
|
||||||
PyErr_Clear();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string python_unicode(PyObject* object)
|
|
||||||
{
|
|
||||||
if (!object || !PyUnicode_Check(object))
|
|
||||||
return {};
|
|
||||||
const char* text = PyUnicode_AsUTF8(object);
|
|
||||||
if (!text) {
|
|
||||||
PyErr_Clear();
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string python_str(PyObject* object)
|
std::string python_str(PyObject* object)
|
||||||
{
|
{
|
||||||
if (!object)
|
if (!object)
|
||||||
@@ -663,6 +964,27 @@ std::string python_str(PyObject* object)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Network audit events expose different shapes for the same thing: a URL, a bare host, or an
|
||||||
|
// address tuple such as ("host", 443). Reduce the argument to the host it names, leaving the
|
||||||
|
// scheme and port to be judged by the permission matcher. None / an empty tuple has no target.
|
||||||
|
std::string python_network_host(PyObject* object)
|
||||||
|
{
|
||||||
|
if (!object || object == Py_None)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
if (PyTuple_Check(object)) {
|
||||||
|
if (PyTuple_GET_SIZE(object) == 0)
|
||||||
|
return {};
|
||||||
|
return python_network_host(PyTuple_GET_ITEM(object, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string text;
|
||||||
|
if (python_text(object, text))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
return python_str(object);
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
||||||
{
|
{
|
||||||
std::vector<std::string> ids;
|
std::vector<std::string> ids;
|
||||||
@@ -766,8 +1088,10 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
|||||||
|
|
||||||
const auto it = audit_target_arg_indices.find(event_name);
|
const auto it = audit_target_arg_indices.find(event_name);
|
||||||
if (it != audit_target_arg_indices.end()) {
|
if (it != audit_target_arg_indices.end()) {
|
||||||
|
const bool network_event = is_network_category(category);
|
||||||
for (const Py_ssize_t index : it->second) {
|
for (const Py_ssize_t index : it->second) {
|
||||||
std::string value = python_str(tuple_item(args, index));
|
std::string value = network_event ? python_network_host(tuple_item(args, index))
|
||||||
|
: python_str(tuple_item(args, index));
|
||||||
if (!value.empty()) {
|
if (!value.empty()) {
|
||||||
targets.push_back(std::move(value));
|
targets.push_back(std::move(value));
|
||||||
break;
|
break;
|
||||||
@@ -797,6 +1121,20 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
|
|||||||
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
return std::find(granted.begin(), granted.end(), target) != granted.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A declared or previously approved network grant may live in either network list: an HTTP grant
|
||||||
|
// covers the sockets the same request opens, and a raw socket grant covers a later HTTP call to
|
||||||
|
// the same host.
|
||||||
|
bool has_network_permission(const PluginPermissions& permissions, const std::string& target)
|
||||||
|
{
|
||||||
|
return PluginAuditManager::network_permission_matches(permissions.network_http, target) ||
|
||||||
|
PluginAuditManager::network_permission_matches(permissions.network_socket, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool has_any_network_permission(const PluginPermissions& permissions)
|
||||||
|
{
|
||||||
|
return !permissions.network_http.empty() || !permissions.network_socket.empty();
|
||||||
|
}
|
||||||
|
|
||||||
bool persist_permission(const std::string& plugin_key,
|
bool persist_permission(const std::string& plugin_key,
|
||||||
PluginInstallState& state,
|
PluginInstallState& state,
|
||||||
std::vector<std::string>& permission_list,
|
std::vector<std::string>& permission_list,
|
||||||
@@ -806,15 +1144,7 @@ bool persist_permission(const std::string& plugin_key,
|
|||||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
|
seed_install_metadata(state, descriptor);
|
||||||
// needed by the writer so clicking Yes still creates the JSON file.
|
|
||||||
if (state.plugin_name.empty()) {
|
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
|
||||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
|
||||||
state.plugin_name = descriptor.name;
|
|
||||||
state.cloud_uuid = descriptor.cloud_uuid();
|
|
||||||
state.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!has_permission(permission_list, target))
|
if (!has_permission(permission_list, target))
|
||||||
permission_list.push_back(target);
|
permission_list.push_back(target);
|
||||||
@@ -893,11 +1223,10 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
|
|||||||
for (const auto& target : unresolved)
|
for (const auto& target : unresolved)
|
||||||
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
||||||
|
|
||||||
wxMessageDialog dialog(nullptr,
|
return show_permission_dialog(
|
||||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()),
|
||||||
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
target_list, unresolved.size()),
|
||||||
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
_L("Plugin permission request"));
|
||||||
return dialog.ShowModal() == wxID_YES;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
||||||
@@ -957,7 +1286,30 @@ int decide_audited_event(PluginAuditManager& mgr,
|
|||||||
const std::vector<std::string>& call_site_ids)
|
const std::vector<std::string>& call_site_ids)
|
||||||
{
|
{
|
||||||
std::vector<std::string> unresolved = targets;
|
std::vector<std::string> unresolved = targets;
|
||||||
if (permission_list) {
|
if (is_network_category(category)) {
|
||||||
|
if (targets.empty()) {
|
||||||
|
// socket.__new__ and http.client.send carry no host. Any network grant already lets
|
||||||
|
// the plugin open a connection, and every host-bearing event still matches its own
|
||||||
|
// grant, so the targetless events are covered by an existing grant instead of
|
||||||
|
// prompting every run. Deliberately do not record a call site here: a targetless
|
||||||
|
// approval must not become an ancestor that later network events could use to skip
|
||||||
|
// their own host check.
|
||||||
|
if (has_any_network_permission(state.permissions))
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
unresolved.clear();
|
||||||
|
for (const auto& target : targets)
|
||||||
|
if (!has_network_permission(state.permissions, target))
|
||||||
|
unresolved.push_back(target);
|
||||||
|
if (unresolved.empty()) {
|
||||||
|
// Remember the stdlib frames for this approved host. The network ancestor cache
|
||||||
|
// is only consulted for a resolved-IP socket.connect/sendto, so this cannot
|
||||||
|
// suppress a later hostname or URL event.
|
||||||
|
mgr.record_approved_network_call_sites(plugin_key, call_site_ids);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (permission_list) {
|
||||||
unresolved.clear();
|
unresolved.clear();
|
||||||
for (const auto& target : targets)
|
for (const auto& target : targets)
|
||||||
if (!has_permission(*permission_list, target))
|
if (!has_permission(*permission_list, target))
|
||||||
@@ -973,6 +1325,12 @@ int decide_audited_event(PluginAuditManager& mgr,
|
|||||||
for (const auto& target : unresolved)
|
for (const auto& target : unresolved)
|
||||||
persist_permission(plugin_key, state, *permission_list, target);
|
persist_permission(plugin_key, state, *permission_list, target);
|
||||||
|
|
||||||
|
// A target-bearing network approval may cover the resolved address of the same connection.
|
||||||
|
// A targetless approval does not: it carries no host, so it must not create an ancestor that
|
||||||
|
// a later hostname/URL check could be skipped by (and the network cache is IP-only anyway).
|
||||||
|
if (is_network_category(category) && !targets.empty())
|
||||||
|
mgr.record_approved_network_call_sites(plugin_key, call_site_ids);
|
||||||
|
|
||||||
mgr.record_approved_call_sites(plugin_key, call_site_ids);
|
mgr.record_approved_call_sites(plugin_key, call_site_ids);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1024,12 +1382,28 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
|||||||
PluginDescriptor plugin_descriptor;
|
PluginDescriptor plugin_descriptor;
|
||||||
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
|
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
|
||||||
|
|
||||||
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
|
// Some plugins call other audited events, e.g. urllib.request will call socket.connect.
|
||||||
// was already approved, socket.connect won't trigger another permission dialog.
|
// Collect the stdlib frames now so an approved network host can cover the resolved address
|
||||||
|
// of the same connection without covering another host.
|
||||||
const std::vector<std::string> call_site_ids =
|
const std::vector<std::string> call_site_ids =
|
||||||
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
|
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
|
||||||
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
|
|
||||||
|
// A general call-site approval may cover nested events for the same logical action. Network
|
||||||
|
// events use a separate rule: a hostname/URL target must match its own grant even when a
|
||||||
|
// parent event was approved, and targetless events are handled by the any-grant rule. Only a
|
||||||
|
// resolved-address connect/sendto (target is an IP literal) may use an approval recorded for
|
||||||
|
// an earlier network host event, because its own target carries no hostname to match.
|
||||||
|
if (is_network_category(event_type)) {
|
||||||
|
const bool all_resolved_addresses =
|
||||||
|
!targets.empty() && std::all_of(targets.begin(), targets.end(), [](const std::string& target) {
|
||||||
|
return PluginAuditManager::is_ip_address_target(target);
|
||||||
|
});
|
||||||
|
if (is_resolved_address_event(event_name) && all_resolved_addresses &&
|
||||||
|
mgr->has_approved_network_ancestor(mgr->current_plugin(), call_site_ids))
|
||||||
|
return 0;
|
||||||
|
} else if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) {
|
||||||
return 0;
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
||||||
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
||||||
|
|||||||
@@ -138,6 +138,33 @@ public:
|
|||||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||||
const std::vector<std::string>& paths);
|
const std::vector<std::string>& paths);
|
||||||
|
|
||||||
|
// Ask the user to grant network access to the given http(s) URLs. Each URL is reduced to its
|
||||||
|
// scheme and host before prompting and persisting, so one approval covers every path, query
|
||||||
|
// and port on that host. The grant is written to both network_http and network_socket: the
|
||||||
|
// same declared host can surface through HTTP events (urllib/http.client) and raw socket
|
||||||
|
// events. Like request_filesystem_read_permissions, the request may originate on a plugin
|
||||||
|
// load worker; denial aborts the plugin load without adding a permission.
|
||||||
|
bool request_network_permissions(const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& urls);
|
||||||
|
|
||||||
|
// Canonical scheme://host form used to store and match network permissions. Returns an empty
|
||||||
|
// string unless `url` is an absolute http(s) URL with a host. The path, query, fragment,
|
||||||
|
// userinfo and port are discarded; scheme and host are lowercased.
|
||||||
|
static std::string normalize_network_url(const std::string& url);
|
||||||
|
|
||||||
|
// True when a granted scheme://host entry covers `target`. `target` is a URL (urllib.Request),
|
||||||
|
// a bare host (http.client.connect, socket.getaddrinfo) or an address as exposed by the audit
|
||||||
|
// event (a hostname, a host:port pair, or a legacy persisted tuple such as "('host', 443)").
|
||||||
|
// URL targets must match both scheme and host. A bare host carries no scheme, so it matches
|
||||||
|
// the host part of any grant; it never matches a different host.
|
||||||
|
static bool network_permission_matches(const std::vector<std::string>& granted,
|
||||||
|
const std::string& target);
|
||||||
|
|
||||||
|
// True when `target` is a bare IPv4/IPv6 literal (optionally bracketed). A network event
|
||||||
|
// carrying an IP literal has no hostname the audit hook can match, so it is the only kind of
|
||||||
|
// network event allowed to use an approved network call-site ancestor.
|
||||||
|
static bool is_ip_address_target(const std::string& target);
|
||||||
|
|
||||||
void report_violation(const AuditViolation& violation);
|
void report_violation(const AuditViolation& violation);
|
||||||
bool audit_denial_pending() const;
|
bool audit_denial_pending() const;
|
||||||
void clear_audit_denial();
|
void clear_audit_denial();
|
||||||
@@ -151,9 +178,20 @@ public:
|
|||||||
// is recorded here by (filename, function, first line) identity. A later event whose own
|
// is recorded here by (filename, function, first line) identity. A later event whose own
|
||||||
// ancestor chain still contains one of those frames is the same logical action seen from
|
// ancestor chain still contains one of those frames is the same logical action seen from
|
||||||
// a deeper layer, so it is auto-approved instead of prompting again.
|
// a deeper layer, so it is auto-approved instead of prompting again.
|
||||||
|
//
|
||||||
|
// Network events do not use this general cache: see the network call-site cache below.
|
||||||
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||||
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||||
|
|
||||||
|
// --- network call-site cache ---
|
||||||
|
// Separate from the general cascade above so a network approval can only suppress a later
|
||||||
|
// network event with no hostname to check: a resolved address (socket.connect/sendto with an
|
||||||
|
// IP literal). Hostname and URL events are never auto-approved by an ancestor; they must
|
||||||
|
// match their own grant. Targetless events are handled by the "any network grant" rule and
|
||||||
|
// never record an entry here.
|
||||||
|
bool has_approved_network_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||||
|
void record_approved_network_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||||
|
|
||||||
bool verbose_events = true;
|
bool verbose_events = true;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -170,12 +208,23 @@ private:
|
|||||||
static thread_local bool m_has_last_violation;
|
static thread_local bool m_has_last_violation;
|
||||||
static thread_local AuditViolation m_last_violation;
|
static thread_local AuditViolation m_last_violation;
|
||||||
|
|
||||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
// mutable: the deny and ancestor queries are const but must lock.
|
||||||
mutable std::mutex m_mutex;
|
mutable std::mutex m_mutex;
|
||||||
|
|
||||||
|
using CallSiteRegistry = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||||
|
|
||||||
|
// Shared lookup/insert mechanics for the two registries below. The caller selects the
|
||||||
|
// registry, so the general and network approvals remain independent.
|
||||||
|
bool has_call_site(const CallSiteRegistry& registry, const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids) const;
|
||||||
|
void record_call_sites(CallSiteRegistry& registry, const std::string& plugin_key,
|
||||||
|
const std::vector<std::string>& call_site_ids);
|
||||||
|
|
||||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||||
std::vector<std::string> m_denied_filenames;
|
std::vector<std::string> m_denied_filenames;
|
||||||
std::vector<std::string> m_denied_path_keywords;
|
std::vector<std::string> m_denied_path_keywords;
|
||||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
CallSiteRegistry m_approved_call_sites;
|
||||||
|
CallSiteRegistry m_approved_network_call_sites;
|
||||||
};
|
};
|
||||||
|
|
||||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||||
|
|||||||
@@ -832,6 +832,29 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The single descriptor -> sidecar metadata mapping. Prefer installed_version (fetched at install
|
||||||
|
// time and preserved across rewrites) over a possibly stale manifest version.
|
||||||
|
void apply_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
|
||||||
|
{
|
||||||
|
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
||||||
|
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
|
||||||
|
state.plugin_name = entry.name;
|
||||||
|
state.cloud_uuid = entry.cloud_uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
|
||||||
|
{
|
||||||
|
if (!state.plugin_name.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
apply_install_metadata(state, entry);
|
||||||
|
state.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
|
||||||
{
|
{
|
||||||
namespace fs = boost::filesystem;
|
namespace fs = boost::filesystem;
|
||||||
@@ -874,16 +897,9 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
|
|||||||
// Loading a plugin updates its lifecycle/capability state, but must retain permissions granted
|
// Loading a plugin updates its lifecycle/capability state, but must retain permissions granted
|
||||||
// during register_capabilities() or by a previous runtime audit prompt.
|
// during register_capabilities() or by a previous runtime audit prompt.
|
||||||
read_install_state(plugin_dir, state);
|
read_install_state(plugin_dir, state);
|
||||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
apply_install_metadata(state, entry);
|
||||||
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud
|
state.enabled = enabled;
|
||||||
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
|
state.capabilities = capabilities;
|
||||||
// never overwrites the source-of-truth version. Fall back to the manifest version for
|
|
||||||
// first-time/local installs where installed_version is not yet populated.
|
|
||||||
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
|
|
||||||
state.plugin_name = entry.name;
|
|
||||||
state.cloud_uuid = entry.cloud_uuid();
|
|
||||||
state.enabled = enabled;
|
|
||||||
state.capabilities = capabilities;
|
|
||||||
return write_install_state(plugin_dir, state);
|
return write_install_state(plugin_dir, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,11 +910,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
|
|||||||
// permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags
|
// permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags
|
||||||
// while preserving permissions once the package is loaded, via the overload above.
|
// while preserving permissions once the package is loaded, via the overload above.
|
||||||
PluginInstallState state;
|
PluginInstallState state;
|
||||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
seed_install_metadata(state, entry);
|
||||||
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
|
|
||||||
state.plugin_name = entry.name;
|
|
||||||
state.cloud_uuid = entry.cloud_uuid();
|
|
||||||
state.enabled = true;
|
|
||||||
return write_install_state(plugin_dir, state);
|
return write_install_state(plugin_dir, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,12 +184,19 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
|
|||||||
|
|
||||||
// Canonical writer: emits the .install_state.json schema for the given state.
|
// Canonical writer: emits the .install_state.json schema for the given state.
|
||||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
|
||||||
|
|
||||||
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
|
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
|
||||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
|
||||||
const std::vector<std::pair<std::string, bool>>& capabilities);
|
const std::vector<std::pair<std::string, bool>>& capabilities);
|
||||||
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
|
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
|
||||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry);
|
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry);
|
||||||
|
|
||||||
|
// Fill the descriptor-derived install metadata into a sidecar state that does not describe an
|
||||||
|
// installed plugin yet, and mark it enabled. Leaves permissions and capabilities untouched.
|
||||||
|
// Single source for the metadata mapping used by the install writer and by the audit prompt path
|
||||||
|
// that creates a sidecar on demand.
|
||||||
|
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry);
|
||||||
|
|
||||||
// Reads install state back into the descriptor; plugin_key is always derived. Returns whether the
|
// Reads install state back into the descriptor; plugin_key is always derived. Returns whether the
|
||||||
// sidecar was present and valid.
|
// sidecar was present and valid.
|
||||||
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry);
|
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry);
|
||||||
|
|||||||
@@ -102,12 +102,6 @@ void install_slicing_pipeline_hook()
|
|||||||
ctx.step = step;
|
ctx.step = step;
|
||||||
ctx.print = &print;
|
ctx.print = &print;
|
||||||
ctx.object = object;
|
ctx.object = object;
|
||||||
// Slicing hooks are synchronous by design and never await: the context
|
|
||||||
// borrows Print/PrintObject that are only valid during this call, and the
|
|
||||||
// UI thread may itself be blocked waiting on this slicing worker. An async
|
|
||||||
// override is rejected here rather than scheduled -- cap->execute() casts
|
|
||||||
// its Python result to ExecutionResult and a coroutine object cannot be
|
|
||||||
// cast, so it surfaces as the SlicingError below.
|
|
||||||
r = cap->execute(ctx);
|
r = cap->execute(ctx);
|
||||||
} catch (const CanceledException&) {
|
} catch (const CanceledException&) {
|
||||||
throw; // cancellation must reach process(), never become a slicing error
|
throw; // cancellation must reach process(), never become a slicing error
|
||||||
|
|||||||
@@ -12,16 +12,11 @@
|
|||||||
|
|
||||||
#include "PluginFsUtils.hpp"
|
#include "PluginFsUtils.hpp"
|
||||||
#include "PluginHooks.hpp"
|
#include "PluginHooks.hpp"
|
||||||
#include "PluginAuditManager.hpp"
|
|
||||||
#include "PyPluginTrampoline.hpp"
|
|
||||||
#include "PythonInterpreter.hpp"
|
#include "PythonInterpreter.hpp"
|
||||||
#include "PythonPluginBridge.hpp"
|
#include "PythonPluginBridge.hpp"
|
||||||
|
|
||||||
#include "OrcaCloudServiceAgent.hpp"
|
#include "OrcaCloudServiceAgent.hpp"
|
||||||
#include "libslic3r/Semver.hpp"
|
#include "libslic3r/Semver.hpp"
|
||||||
#include "pybind11/pytypes.h"
|
|
||||||
#include "pybind11/gil.h"
|
|
||||||
#include "pybind11/pybind11.h"
|
|
||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
#include "slic3r/GUI/I18N.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
#include "slic3r/GUI/NotificationManager.hpp"
|
#include "slic3r/GUI/NotificationManager.hpp"
|
||||||
@@ -46,7 +41,6 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <wx/app.h>
|
#include <wx/app.h>
|
||||||
#include <wx/thread.h>
|
#include <wx/thread.h>
|
||||||
#include <wx/utils.h>
|
|
||||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||||
#include "slic3r/GUI/Plater.hpp"
|
#include "slic3r/GUI/Plater.hpp"
|
||||||
|
|
||||||
@@ -74,10 +68,8 @@ void Plugin::release_module()
|
|||||||
PluginManager& PluginManager::instance()
|
PluginManager& PluginManager::instance()
|
||||||
{
|
{
|
||||||
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
|
||||||
// release module, sys.modules, and sys.path ownership safely. Touch the async Script runtime
|
// release module, sys.modules, and sys.path ownership safely.
|
||||||
// next, so ~PluginManager can still drain it during its own shutdown().
|
|
||||||
PythonInterpreter::instance();
|
PythonInterpreter::instance();
|
||||||
ScriptAsyncRuntime::instance();
|
|
||||||
static PluginManager inst;
|
static PluginManager inst;
|
||||||
return inst;
|
return inst;
|
||||||
}
|
}
|
||||||
@@ -109,9 +101,6 @@ bool PluginManager::initialize()
|
|||||||
m_initialized = true;
|
m_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-arm the async Script runtime in case shutdown() ran earlier in this process.
|
|
||||||
ScriptAsyncRuntime::instance().reset();
|
|
||||||
|
|
||||||
// Bring every capability's stored config into memory. Deliberately unconditional and
|
// Bring every capability's stored config into memory. Deliberately unconditional and
|
||||||
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
||||||
// plugin that comes back later finds its settings intact. A missing or malformed file just
|
// plugin that comes back later finds its settings intact. A missing or malformed file just
|
||||||
@@ -178,10 +167,6 @@ void PluginManager::shutdown()
|
|||||||
unload_all_plugins();
|
unload_all_plugins();
|
||||||
PythonPluginBridge::instance().clear_pending_captures();
|
PythonPluginBridge::instance().clear_pending_captures();
|
||||||
|
|
||||||
// Every async Script run has been drained by unload_all_plugins above; wait for every
|
|
||||||
// invocation worker before the interpreter is allowed to finalize.
|
|
||||||
ScriptAsyncRuntime::instance().shutdown();
|
|
||||||
|
|
||||||
// Every config write already goes to disk as it happens (store_capability_config), so this
|
// Every config write already goes to disk as it happens (store_capability_config), so this
|
||||||
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
||||||
// plugins above must never discard their stored config.
|
// plugins above must never discard their stored config.
|
||||||
@@ -769,10 +754,6 @@ void PluginManager::release_load_slot(const std::string& plugin_key)
|
|||||||
void PluginManager::cancel_and_wait_for_capabilities(
|
void PluginManager::cancel_and_wait_for_capabilities(
|
||||||
const std::vector<std::shared_ptr<PluginCapabilityInterface>>& capabilities)
|
const std::vector<std::shared_ptr<PluginCapabilityInterface>>& capabilities)
|
||||||
{
|
{
|
||||||
// Ask the plugin to cancel first, then schedule the async cancellation: on_cancelled() can
|
|
||||||
// observe/record state while the run is still intact, and only afterwards is its asyncio task
|
|
||||||
// cancelled. Draining of capability references still follows, so the module is not unloaded
|
|
||||||
// until the run has settled.
|
|
||||||
for (const auto& capability : capabilities) {
|
for (const auto& capability : capabilities) {
|
||||||
if (!capability || !capability->is_enabled())
|
if (!capability || !capability->is_enabled())
|
||||||
continue;
|
continue;
|
||||||
@@ -788,37 +769,15 @@ void PluginManager::cancel_and_wait_for_capabilities(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& capability : capabilities) {
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||||
if (capability)
|
while (std::chrono::steady_clock::now() < deadline) {
|
||||||
ScriptAsyncRuntime::instance().cancel_plugin(capability->audit_plugin_key());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Script rows are bounded only by their own worker: a cancelled or stalled invocation of another
|
|
||||||
// plugin can no longer couple to this one (each invocation owns a thread). A Script worker is
|
|
||||||
// drained without a deadline — forcing module unload while one is outstanding would leave Python
|
|
||||||
// executing against a freed module, or let a blocking preset selection mutate presets after the
|
|
||||||
// plugin is gone. Non-Script synchronous calls keep the historical bounded grace. On the GUI
|
|
||||||
// thread the event loop is pumped so a modal selection can settle instead of being starved by a
|
|
||||||
// blocking wait.
|
|
||||||
const auto grace_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
|
||||||
for (;;) {
|
|
||||||
const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
const bool all_released = std::all_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
||||||
return !capability || capability->ref_count() == 0;
|
return !capability || capability->ref_count() == 0;
|
||||||
});
|
});
|
||||||
// A cancellation that arrived before the invocation ran skips the ref counter, so an active
|
if (all_released)
|
||||||
// worker must also be gone: never unload the module while any Script thread is still live.
|
|
||||||
const bool invocation_active = std::any_of(capabilities.begin(), capabilities.end(), [](const auto& capability) {
|
|
||||||
return capability != nullptr && ScriptAsyncRuntime::instance().has_active_run(capability.get());
|
|
||||||
});
|
|
||||||
if (all_released && !invocation_active)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!invocation_active && std::chrono::steady_clock::now() >= grace_deadline)
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
break;
|
|
||||||
|
|
||||||
if (wxTheApp != nullptr && wxIsMainThread())
|
|
||||||
wxYield();
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& capability : capabilities) {
|
for (const auto& capability : capabilities) {
|
||||||
@@ -1221,12 +1180,8 @@ void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin
|
|||||||
|
|
||||||
const boost::filesystem::path root(descriptor.plugin_root);
|
const boost::filesystem::path root(descriptor.plugin_root);
|
||||||
PluginInstallState state;
|
PluginInstallState state;
|
||||||
if (!read_install_state(root, state)) {
|
if (!read_install_state(root, state))
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
seed_install_metadata(state, descriptor);
|
||||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
|
||||||
state.plugin_name = descriptor.name;
|
|
||||||
state.cloud_uuid = descriptor.cloud_uuid();
|
|
||||||
}
|
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
if (!write_install_state(root, state))
|
if (!write_install_state(root, state))
|
||||||
return;
|
return;
|
||||||
@@ -2111,385 +2066,41 @@ bool PluginManager::delete_and_unsubscribe_cloud_plugin(const std::string& plugi
|
|||||||
return finalize_cloud_plugin_removal(descriptor, false, error);
|
return finalize_cloud_plugin_removal(descriptor, false, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Async Script runtime ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// The asyncio loop of the Script invocation running on the calling thread. Owned +1 while set;
|
|
||||||
// always cleared under the GIL before the worker thread exits.
|
|
||||||
thread_local PyObject* tl_script_loop = nullptr;
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
struct ScriptAsyncRuntime::Impl
|
|
||||||
{
|
|
||||||
struct Run
|
|
||||||
{
|
|
||||||
std::string plugin_key;
|
|
||||||
std::string capability_name;
|
|
||||||
std::shared_ptr<PluginCapabilityInterface> capability;
|
|
||||||
std::function<void(const ScriptCompletion&)> on_done;
|
|
||||||
std::atomic<bool> cancel_requested{false};
|
|
||||||
// True while an asyncio loop/task is live for this run. Read without the GIL by
|
|
||||||
// cancel_plugin() to avoid taking the GIL for a purely synchronous invocation.
|
|
||||||
std::atomic<bool> has_loop{false};
|
|
||||||
|
|
||||||
// Live asyncio loop/task for cancellation. Guarded by handle_mutex and only touched
|
|
||||||
// with the GIL held.
|
|
||||||
std::mutex handle_mutex;
|
|
||||||
pybind11::object loop;
|
|
||||||
pybind11::object task;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::mutex mutex;
|
|
||||||
std::condition_variable cv;
|
|
||||||
std::vector<std::shared_ptr<Run>> active;
|
|
||||||
bool stopping = false;
|
|
||||||
|
|
||||||
// Runs one invocation to completion on the calling (worker) thread.
|
|
||||||
void run_invocation(const std::shared_ptr<Run>& run);
|
|
||||||
};
|
|
||||||
|
|
||||||
void ScriptAsyncRuntime::Impl::run_invocation(const std::shared_ptr<Run>& run)
|
|
||||||
{
|
|
||||||
ScriptCompletion completion;
|
|
||||||
|
|
||||||
{
|
|
||||||
PythonRuntimeLease lease = PythonInterpreter::instance().acquire_runtime_lease();
|
|
||||||
if (!lease) {
|
|
||||||
completion.error = _u8L("Python interpreter is shutting down");
|
|
||||||
} else {
|
|
||||||
pybind11::gil_scoped_acquire gil;
|
|
||||||
|
|
||||||
if (run->cancel_requested.load(std::memory_order_acquire)) {
|
|
||||||
// Cancelled before it ran: expected termination, never an error.
|
|
||||||
completion.cancelled = true;
|
|
||||||
} else {
|
|
||||||
// Hold the capability reference, the audit scope and the GIL for the whole
|
|
||||||
// invocation, so unload/shutdown cannot tear the module out from under it.
|
|
||||||
PluginCapabilityInterface::RefCounter ref_counter(*run->capability);
|
|
||||||
|
|
||||||
std::optional<ScopedPluginAuditContext> audit_scope;
|
|
||||||
if (!run->capability->audit_plugin_key().empty())
|
|
||||||
audit_scope.emplace(run->capability->audit_plugin_key(), run->capability->name());
|
|
||||||
|
|
||||||
// Detection and execution both happen here, on the worker: a synchronous
|
|
||||||
// `def execute` runs to completion off the UI thread, exactly like an `async def`.
|
|
||||||
ScriptExecuteInvocation invocation = invoke_script_execute(run->capability);
|
|
||||||
if (!invocation.error.empty()) {
|
|
||||||
completion.error = invocation.error;
|
|
||||||
} else if (!invocation.is_async) {
|
|
||||||
completion.result = invocation.result;
|
|
||||||
} else if (run->cancel_requested.load(std::memory_order_acquire)) {
|
|
||||||
completion.cancelled = true;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
pybind11::module_ asyncio = pybind11::module_::import("asyncio");
|
|
||||||
pybind11::object loop = asyncio.attr("new_event_loop")();
|
|
||||||
asyncio.attr("set_event_loop")(loop);
|
|
||||||
|
|
||||||
tl_script_loop = loop.ptr();
|
|
||||||
Py_INCREF(tl_script_loop);
|
|
||||||
|
|
||||||
pybind11::object task = loop.attr("create_task")(std::move(invocation.awaitable));
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
|
||||||
run->loop = loop;
|
|
||||||
run->task = task;
|
|
||||||
}
|
|
||||||
run->has_loop.store(true, std::memory_order_release);
|
|
||||||
// A cancellation that arrived before the loop came up must still land.
|
|
||||||
if (run->cancel_requested.load(std::memory_order_acquire))
|
|
||||||
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
pybind11::object returned = loop.attr("run_until_complete")(task);
|
|
||||||
completion.result = returned.cast<ExecutionResult>();
|
|
||||||
} catch (pybind11::error_already_set& err) {
|
|
||||||
// Only our own cancel_plugin() cancellation is expected termination; a
|
|
||||||
// CancelledError the plugin raised on its own stays a real error.
|
|
||||||
if (err.matches(asyncio.attr("CancelledError")) &&
|
|
||||||
run->cancel_requested.load(std::memory_order_acquire)) {
|
|
||||||
completion.cancelled = true;
|
|
||||||
} else {
|
|
||||||
log_python_exception_keep(err);
|
|
||||||
completion.error = err.what();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run->has_loop.store(false, std::memory_order_release);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
|
||||||
run->task = pybind11::object();
|
|
||||||
run->loop = pybind11::object();
|
|
||||||
}
|
|
||||||
Py_CLEAR(tl_script_loop);
|
|
||||||
loop.attr("close")();
|
|
||||||
asyncio.attr("set_event_loop")(pybind11::none());
|
|
||||||
} catch (pybind11::error_already_set& err) {
|
|
||||||
log_python_exception_keep(err);
|
|
||||||
completion.error = err.what();
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
completion.error = ex.what();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Covers an exception thrown after the loop came up but before the clear above.
|
|
||||||
if (run->has_loop.load(std::memory_order_acquire)) {
|
|
||||||
run->has_loop.store(false, std::memory_order_release);
|
|
||||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
|
||||||
run->task = pybind11::object();
|
|
||||||
run->loop = pybind11::object();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tl_script_loop != nullptr)
|
|
||||||
Py_CLEAR(tl_script_loop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the capability reference while the GIL is held: releasing a pybind holder from
|
|
||||||
// a non-Python thread would DECREF the capability without the GIL. Runs on every
|
|
||||||
// lease-valid path, including an invocation cancelled before it ran.
|
|
||||||
run->capability.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination and is
|
|
||||||
// authoritative: whatever the body did — finished, swallowed CancelledError, or failed — nothing
|
|
||||||
// is surfaced as an error or as a normal success.
|
|
||||||
if (run->cancel_requested.load(std::memory_order_acquire)) {
|
|
||||||
completion.cancelled = true;
|
|
||||||
completion.error.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto on_done = std::move(run->on_done);
|
|
||||||
auto deliver = [on_done, completion]() {
|
|
||||||
if (on_done)
|
|
||||||
on_done(completion);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (wxTheApp == nullptr || wxIsMainThread())
|
|
||||||
deliver();
|
|
||||||
else
|
|
||||||
GUI::wxGetApp().CallAfter(deliver);
|
|
||||||
|
|
||||||
// Leave `active` last, so a drain cannot observe the run as finished before its completion was
|
|
||||||
// queued/delivered and its capability reference released.
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mutex);
|
|
||||||
active.erase(std::remove(active.begin(), active.end(), run), active.end());
|
|
||||||
}
|
|
||||||
cv.notify_all();
|
|
||||||
}
|
|
||||||
|
|
||||||
ScriptAsyncRuntime& ScriptAsyncRuntime::instance()
|
|
||||||
{
|
|
||||||
static ScriptAsyncRuntime inst;
|
|
||||||
return inst;
|
|
||||||
}
|
|
||||||
|
|
||||||
ScriptAsyncRuntime::ScriptAsyncRuntime() : m_impl(std::make_unique<Impl>()) {}
|
|
||||||
|
|
||||||
ScriptAsyncRuntime::~ScriptAsyncRuntime() { shutdown(); }
|
|
||||||
|
|
||||||
bool ScriptAsyncRuntime::start(const std::string& plugin_key,
|
|
||||||
const std::string& capability_name,
|
|
||||||
std::shared_ptr<PluginCapabilityInterface> capability,
|
|
||||||
std::function<void(const ScriptCompletion&)> on_done)
|
|
||||||
{
|
|
||||||
if (!PythonInterpreter::instance().is_initialized())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
auto run = std::make_shared<Impl::Run>();
|
|
||||||
run->plugin_key = plugin_key;
|
|
||||||
run->capability_name = capability_name;
|
|
||||||
run->capability = std::move(capability);
|
|
||||||
run->on_done = std::move(on_done);
|
|
||||||
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
|
||||||
if (m_impl->stopping)
|
|
||||||
return false;
|
|
||||||
m_impl->active.push_back(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One worker thread per invocation. A stalled invocation of one plugin can never delay another
|
|
||||||
// plugin's unload or another invocation, and no invocation body ever runs on the caller (UI)
|
|
||||||
// thread. The thread holds `run` (and so the capability) until it finishes; the runtime tracks
|
|
||||||
// it in `active` and waits for it on shutdown.
|
|
||||||
Impl* impl = m_impl.get();
|
|
||||||
try {
|
|
||||||
std::thread([impl, run] { impl->run_invocation(run); }).detach();
|
|
||||||
} catch (...) {
|
|
||||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
|
||||||
m_impl->active.erase(std::remove(m_impl->active.begin(), m_impl->active.end(), run), m_impl->active.end());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScriptAsyncRuntime::cancel_plugin(const std::string& plugin_key)
|
|
||||||
{
|
|
||||||
std::vector<std::shared_ptr<Impl::Run>> runs;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
|
||||||
for (const auto& run : m_impl->active)
|
|
||||||
if (run->plugin_key == plugin_key)
|
|
||||||
runs.push_back(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const auto& run : runs)
|
|
||||||
run->cancel_requested.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
for (const auto& run : runs) {
|
|
||||||
// A synchronous invocation has no loop to cancel; skip it so the caller never blocks on
|
|
||||||
// the GIL while that invocation is (uninterruptibly) running.
|
|
||||||
if (!run->has_loop.load(std::memory_order_acquire))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
PythonGILState gil;
|
|
||||||
if (!gil)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
pybind11::object loop;
|
|
||||||
pybind11::object task;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(run->handle_mutex);
|
|
||||||
loop = run->loop;
|
|
||||||
task = run->task;
|
|
||||||
}
|
|
||||||
if (!loop || !task)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
loop.attr("call_soon_threadsafe")(task.attr("cancel"));
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Script async cancel failed for plugin '" << plugin_key << "': " << ex.what();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ScriptAsyncRuntime::has_active_run(const PluginCapabilityInterface* capability) const
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
|
||||||
for (const auto& run : m_impl->active)
|
|
||||||
if (run->capability.get() == capability)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScriptAsyncRuntime::shutdown()
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
|
||||||
m_impl->stopping = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::unique_lock<std::mutex> lock(m_impl->mutex);
|
|
||||||
m_impl->cv.wait(lock, [this] { return m_impl->active.empty(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
void ScriptAsyncRuntime::reset()
|
|
||||||
{
|
|
||||||
shutdown();
|
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_impl->mutex);
|
|
||||||
m_impl->stopping = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pybind11::object ScriptAsyncRuntime::current_loop()
|
|
||||||
{
|
|
||||||
if (tl_script_loop == nullptr)
|
|
||||||
return pybind11::object();
|
|
||||||
return pybind11::reinterpret_borrow<pybind11::object>(pybind11::handle(tl_script_loop));
|
|
||||||
}
|
|
||||||
|
|
||||||
ExecutionResult PluginManager::run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error)
|
ExecutionResult PluginManager::run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error)
|
||||||
{
|
{
|
||||||
ExecutionResult result;
|
if (plugin_key.empty() || capability_name.empty()) {
|
||||||
std::mutex mutex;
|
return {};
|
||||||
std::condition_variable cv;
|
|
||||||
bool done = false;
|
|
||||||
|
|
||||||
ScriptLaunchResult launch = launch_script_capability(
|
|
||||||
plugin_key, capability_name, [&](const ScriptCompletion& completion) {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex);
|
|
||||||
result = completion.result;
|
|
||||||
// A cancel_plugin() cancellation is expected termination, not an error to report.
|
|
||||||
error = completion.cancelled ? std::string() : completion.error;
|
|
||||||
done = true;
|
|
||||||
cv.notify_all();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (launch.launched_async) {
|
|
||||||
// Async run: the completion is delivered on the GUI thread. When this call is itself on
|
|
||||||
// the GUI thread it must pump the event loop, or the completion can never be delivered.
|
|
||||||
std::unique_lock<std::mutex> lock(mutex);
|
|
||||||
if (wxTheApp != nullptr && wxIsMainThread()) {
|
|
||||||
while (!done) {
|
|
||||||
cv.wait_for(lock, std::chrono::milliseconds(10));
|
|
||||||
lock.unlock();
|
|
||||||
wxYield();
|
|
||||||
lock.lock();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cv.wait(lock, [&] { return done; });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result = launch.outcome.result;
|
|
||||||
error = launch.outcome.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
PluginManager::ScriptLaunchResult PluginManager::launch_script_capability(const std::string& plugin_key,
|
|
||||||
const std::string& capability_name,
|
|
||||||
ScriptCompletionFn on_done)
|
|
||||||
{
|
|
||||||
ScriptLaunchResult launch;
|
|
||||||
|
|
||||||
// Mirror the historical synchronous behavior for a dangling reference: nothing to run and
|
|
||||||
// no error (the caller's guarded UI normally prevents this).
|
|
||||||
if (plugin_key.empty() || capability_name.empty())
|
|
||||||
return launch;
|
|
||||||
|
|
||||||
if (m_shutting_down.load(std::memory_order_acquire)) {
|
|
||||||
launch.outcome.error = _u8L("Plugin system is shutting down.");
|
|
||||||
return launch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
|
auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
|
||||||
if (!cap)
|
if (!cap)
|
||||||
return launch;
|
return {};
|
||||||
|
|
||||||
if (!std::dynamic_pointer_cast<ScriptPluginCapability>(cap))
|
auto cap_interface = std::dynamic_pointer_cast<ScriptPluginCapability>(cap);
|
||||||
return launch;
|
if (!cap_interface)
|
||||||
|
return {};
|
||||||
|
|
||||||
// Every Script body runs off the caller's thread now: the invocation — including detection and
|
ExecutionResult result;
|
||||||
// a synchronous `def execute` — is handed to the runtime, which runs it on its own worker and
|
try {
|
||||||
// reports the outcome through the completion callback. Nothing executes before returning here,
|
PythonGILState gil;
|
||||||
// so the UI thread is never blocked by a Script invocation.
|
if (!gil) {
|
||||||
const bool started = ScriptAsyncRuntime::instance().start(
|
error = "Python interpreter is shutting down";
|
||||||
plugin_key, capability_name, cap,
|
return {};
|
||||||
[this, plugin_key, on_done](const ScriptCompletion& completion) {
|
}
|
||||||
// A cancellation requested by cancel_plugin() (unload/shutdown) is expected termination:
|
result = cap_interface->execute();
|
||||||
// record no plugin error and surface nothing. Genuine user/plugin errors still record.
|
} catch (const std::exception& ex) {
|
||||||
if (!completion.cancelled && !completion.error.empty())
|
error = ex.what();
|
||||||
set_plugin_error(plugin_key, completion.error);
|
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
|
||||||
if (on_done)
|
} catch (...) {
|
||||||
on_done(completion);
|
error = _u8L("Unknown error");
|
||||||
});
|
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
|
||||||
|
|
||||||
if (!started) {
|
|
||||||
launch.outcome.error = _u8L("Plugin system is shutting down.");
|
|
||||||
set_plugin_error(plugin_key, launch.outcome.error);
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution failed. plugin_key=" << plugin_key
|
|
||||||
<< " error=" << launch.outcome.error;
|
|
||||||
return launch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
launch.launched_async = true;
|
if (!error.empty()) {
|
||||||
return launch;
|
set_plugin_error(plugin_key, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {
|
void PluginManager::dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include "pybind11/pytypes.h"
|
|
||||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -99,19 +98,6 @@ struct Plugin
|
|||||||
void release_module();
|
void release_module();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outcome of one Script invocation, delivered to a script completion callback.
|
|
||||||
//
|
|
||||||
// `cancelled` marks a run terminated by PluginManager's own cancellation (plugin unload / shutdown):
|
|
||||||
// expected termination, not a failure. Callers must not surface it as an error nor as a normal
|
|
||||||
// success. `error` carries only genuine user/plugin exceptions; `result` is meaningful when neither
|
|
||||||
// `cancelled` nor a non-empty `error` is set.
|
|
||||||
struct ScriptCompletion
|
|
||||||
{
|
|
||||||
ExecutionResult result;
|
|
||||||
std::string error;
|
|
||||||
bool cancelled = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
class PluginManager
|
class PluginManager
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -228,25 +214,6 @@ public:
|
|||||||
|
|
||||||
ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error);
|
ExecutionResult run_script_capability(const std::string& plugin_key, const std::string& capability_name, std::string& error);
|
||||||
|
|
||||||
// Outcome of launching a Script capability.
|
|
||||||
struct ScriptLaunchResult
|
|
||||||
{
|
|
||||||
bool launched_async = false; // true => a run started off-thread; wait for on_done
|
|
||||||
ScriptCompletion outcome; // meaningful when !launched_async (start failure only)
|
|
||||||
};
|
|
||||||
// Invoked on the GUI thread when a Script invocation finishes (or inline, on the calling thread,
|
|
||||||
// when the run could not be started at all).
|
|
||||||
using ScriptCompletionFn = std::function<void(const ScriptCompletion&)>;
|
|
||||||
|
|
||||||
// Starts a Script capability off the caller's thread. Both a synchronous `def execute` and an
|
|
||||||
// `async def execute` run on a ScriptAsyncRuntime worker, so this never blocks the UI thread and
|
|
||||||
// no invocation runs before returning. `launched_async` is true exactly when a run started; the
|
|
||||||
// caller then waits for on_done instead of a returned result. When it is false, `outcome.error`
|
|
||||||
// explains why (the caller's guarded UI normally prevents this).
|
|
||||||
ScriptLaunchResult launch_script_capability(const std::string& plugin_key,
|
|
||||||
const std::string& capability_name,
|
|
||||||
ScriptCompletionFn on_done = {});
|
|
||||||
|
|
||||||
void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx);
|
void dispatch_lifecycle_event(LifecycleEvent evt, const LifecycleEventContext& ctx);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -342,74 +309,6 @@ private:
|
|||||||
std::atomic<bool> m_shutting_down{false};
|
std::atomic<bool> m_shutting_down{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Async Script execution ──────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// A Script capability whose Python `execute` is an `async def` (or otherwise returns a
|
|
||||||
// coroutine) must not run on the UI thread, and cannot use the synchronous trampoline path,
|
|
||||||
// which casts the return value to ExecutionResult and would reject a coroutine.
|
|
||||||
//
|
|
||||||
// ScriptAsyncRuntime drives such executions off the calling (UI) thread. Each invocation gets its
|
|
||||||
// own worker thread and, for an `async def`, its own asyncio event loop. Every invocation body —
|
|
||||||
// detection, a synchronous `def execute`, or an `async def` coroutine — runs on that worker, so no
|
|
||||||
// Script invocation ever blocks the UI thread and no invocation is queued behind another plugin's.
|
|
||||||
// For the whole invocation the worker holds:
|
|
||||||
// * a shared_ptr to the capability, so the capability and its module stay alive across awaits;
|
|
||||||
// * a PythonRuntimeLease plus the GIL, so the interpreter cannot finalize underneath it;
|
|
||||||
// * the capability's audit scope (ScopedPluginAuditContext),
|
|
||||||
// which keeps plugin unload and interpreter shutdown from tearing the module out from under a
|
|
||||||
// suspended run. Unload cancels the run and drains it (it never force-unloads after a timeout).
|
|
||||||
//
|
|
||||||
// Interface for host awaitables (see plugin/host/): a host awaitable is constructed inside the
|
|
||||||
// Script coroutine, i.e. on the worker thread with that invocation's loop running, so it can
|
|
||||||
// read that loop with asyncio.get_running_loop() or ScriptAsyncRuntime::current_loop(). To
|
|
||||||
// resume from another thread (typically the UI thread) it must resolve its future with
|
|
||||||
// loop.call_soon_threadsafe(...) and must never touch plugin Python objects, or the loop,
|
|
||||||
// directly from that other thread. The loop only runs while the coroutine is suspended inside
|
|
||||||
// run_until_complete; once Script execute returns the loop is closed and the module may unload.
|
|
||||||
class ScriptAsyncRuntime
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
static ScriptAsyncRuntime& instance();
|
|
||||||
|
|
||||||
~ScriptAsyncRuntime();
|
|
||||||
ScriptAsyncRuntime(const ScriptAsyncRuntime&) = delete;
|
|
||||||
ScriptAsyncRuntime& operator=(const ScriptAsyncRuntime&) = delete;
|
|
||||||
|
|
||||||
// Starts one Script invocation on its own worker thread. The invocation body (Python `execute`,
|
|
||||||
// whether `def` or `async def`) and its detection both run on that worker, never on the calling
|
|
||||||
// thread. `on_done` is invoked on the GUI thread (or inline when there is no wx app) when the
|
|
||||||
// invocation finishes; a `cancelled` completion is expected termination from cancel_plugin().
|
|
||||||
// Returns false when the runtime is shutting down.
|
|
||||||
bool start(const std::string& plugin_key,
|
|
||||||
const std::string& capability_name,
|
|
||||||
std::shared_ptr<PluginCapabilityInterface> capability,
|
|
||||||
std::function<void(const ScriptCompletion&)> on_done);
|
|
||||||
|
|
||||||
// Requests cancellation of every active run owned by plugin_key: marks the run and cancels
|
|
||||||
// its asyncio task via loop.call_soon_threadsafe (a synchronous invocation has no loop).
|
|
||||||
void cancel_plugin(const std::string& plugin_key);
|
|
||||||
|
|
||||||
// True while a run for `capability` is active.
|
|
||||||
bool has_active_run(const PluginCapabilityInterface* capability) const;
|
|
||||||
|
|
||||||
// Stops accepting runs and waits for every in-flight invocation worker. Nothing is torn down
|
|
||||||
// by force.
|
|
||||||
void shutdown();
|
|
||||||
|
|
||||||
// Re-arms the runtime after a shutdown() so a later initialize() in the same process can
|
|
||||||
// start runs again.
|
|
||||||
void reset();
|
|
||||||
|
|
||||||
// The asyncio loop of the Script invocation running on the calling thread, or an empty
|
|
||||||
// object when the calling thread is not running one. Must be called with the GIL held.
|
|
||||||
static pybind11::object current_loop();
|
|
||||||
|
|
||||||
private:
|
|
||||||
ScriptAsyncRuntime();
|
|
||||||
struct Impl;
|
|
||||||
std::unique_ptr<Impl> m_impl;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve each configured capability reference to a loaded capability of type T and run `execute`.
|
// Resolve each configured capability reference to a loaded capability of type T and run `execute`.
|
||||||
template<typename T>
|
template<typename T>
|
||||||
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
|
void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
#define slic3r_PyPluginTrampoline_hpp_
|
#define slic3r_PyPluginTrampoline_hpp_
|
||||||
|
|
||||||
#include "libslic3r/LifecycleEvents.hpp"
|
#include "libslic3r/LifecycleEvents.hpp"
|
||||||
#include <exception>
|
|
||||||
#include <pybind11/embed.h>
|
#include <pybind11/embed.h>
|
||||||
|
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -16,11 +14,9 @@
|
|||||||
#include "PythonInterpreter.hpp"
|
#include "PythonInterpreter.hpp"
|
||||||
#include "PluginFsUtils.hpp"
|
#include "PluginFsUtils.hpp"
|
||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
#include "pybind11/cast.h"
|
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
#include <pybind11/gil.h>
|
#include <pybind11/gil.h>
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
|
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
|
||||||
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
|
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
|
||||||
@@ -65,78 +61,6 @@
|
|||||||
} while (0)
|
} while (0)
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
// True when `object` is a coroutine object (the value produced by calling an `async def`).
|
|
||||||
// Match inspect.iscoroutine() without importing Python modules inside the plugin audit scope.
|
|
||||||
// Must be called with the GIL held.
|
|
||||||
inline bool py_is_coroutine(const pybind11::handle& object)
|
|
||||||
{
|
|
||||||
return object && PyCoro_CheckExact(object.ptr());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result of invoking a Script capability's Python `execute` exactly once.
|
|
||||||
//
|
|
||||||
// A synchronous override (`def execute`) runs to completion here and yields `result`.
|
|
||||||
// An asynchronous override (`async def execute`, or any override that returns a coroutine)
|
|
||||||
// yields the coroutine object in `awaitable` WITHOUT running it: the cast to ExecutionResult
|
|
||||||
// that the trampoline would otherwise perform is impossible for a coroutine, so the caller
|
|
||||||
// chooses how to drive it. Whoever drives it must keep the capability referenced and re-open
|
|
||||||
// this call's GIL + runtime lease + audit scope for the coroutine's whole lifetime (see
|
|
||||||
// ScriptAsyncRuntime in PluginManager.hpp).
|
|
||||||
struct ScriptExecuteInvocation
|
|
||||||
{
|
|
||||||
bool is_async = false;
|
|
||||||
pybind11::object awaitable; // valid iff is_async; a coroutine object created under the GIL
|
|
||||||
ExecutionResult result; // valid iff !is_async
|
|
||||||
std::string error; // non-empty when the call or coroutine creation raised
|
|
||||||
};
|
|
||||||
|
|
||||||
// Invokes ScriptPluginCapability::execute() under the GIL, replicating the audited trampoline
|
|
||||||
// call (capability ref counter, audit scope, traceback-preserving rethrow) but returning a
|
|
||||||
// coroutine object instead of forcing a cast when the plugin's `execute` is `async def`.
|
|
||||||
//
|
|
||||||
// `capability` must be the concrete, Python-bound capability instance (obtainable from
|
|
||||||
// get_plugin_capability()); it is resolved back to its Python object through pybind's instance
|
|
||||||
// map. Never throws: a Python exception is logged (full traceback) and reported in `error`.
|
|
||||||
inline ScriptExecuteInvocation invoke_script_execute(const std::shared_ptr<PluginCapabilityInterface>& capability)
|
|
||||||
{
|
|
||||||
ScriptExecuteInvocation invocation;
|
|
||||||
if (!capability)
|
|
||||||
return invocation;
|
|
||||||
|
|
||||||
try {
|
|
||||||
pybind11::gil_scoped_acquire gil;
|
|
||||||
PluginCapabilityInterface::RefCounter ref_counter(*capability);
|
|
||||||
|
|
||||||
std::optional<ScopedPluginAuditContext> audit_scope;
|
|
||||||
if (const std::string& audit_key = capability->audit_plugin_key(); !audit_key.empty())
|
|
||||||
audit_scope.emplace(audit_key, capability->name());
|
|
||||||
|
|
||||||
pybind11::object py_capability = pybind11::cast(capability);
|
|
||||||
pybind11::object returned = py_capability.attr("execute")();
|
|
||||||
|
|
||||||
if (py_is_coroutine(returned)) {
|
|
||||||
invocation.is_async = true;
|
|
||||||
invocation.awaitable = std::move(returned);
|
|
||||||
return invocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
invocation.result = returned.cast<ExecutionResult>();
|
|
||||||
} catch (pybind11::error_already_set& err) {
|
|
||||||
const bool audit_denial = PluginAuditManager::instance().audit_denial_pending();
|
|
||||||
if (audit_denial)
|
|
||||||
PluginAuditManager::instance().clear_current_plugin();
|
|
||||||
invocation.error = err.what();
|
|
||||||
log_python_exception_keep(err);
|
|
||||||
if (audit_denial)
|
|
||||||
PluginAuditManager::instance().clear_audit_denial();
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
invocation.error = ex.what();
|
|
||||||
}
|
|
||||||
|
|
||||||
return invocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
template<class Base> class PyPluginCommonTrampoline : public Base
|
template<class Base> class PyPluginCommonTrampoline : public Base
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -584,6 +584,19 @@ bool PythonInterpreter::initialize()
|
|||||||
}
|
}
|
||||||
BOOST_LOG_TRIVIAL(debug) << "Program name set successfully";
|
BOOST_LOG_TRIVIAL(debug) << "Program name set successfully";
|
||||||
|
|
||||||
|
// Keep bytecode out of the Python home. The shipped .pyc files are stale against their
|
||||||
|
// sources, so a writable install would rewrite them inside the app (breaking a signed
|
||||||
|
// macOS bundle's seal) and a read-only one would recompile them on every launch.
|
||||||
|
const std::string pycache_prefix = data_dir() + "/python/pycache";
|
||||||
|
#ifdef _WIN32
|
||||||
|
status = PyConfig_SetString(&config, &config.pycache_prefix, boost::nowide::widen(pycache_prefix).c_str());
|
||||||
|
#else
|
||||||
|
status = PyConfig_SetBytesString(&config, &config.pycache_prefix, pycache_prefix.c_str());
|
||||||
|
#endif
|
||||||
|
if (PyStatus_Exception(status))
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Failed to set the Python bytecode cache to " << pycache_prefix << ": "
|
||||||
|
<< (status.err_msg ? status.err_msg : "unknown error");
|
||||||
|
|
||||||
// Use pybind11's scoped_interpreter which properly initializes
|
// Use pybind11's scoped_interpreter which properly initializes
|
||||||
// pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig
|
// pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig
|
||||||
// does not set up thread state tracking that pybind11 requires.
|
// does not set up thread state tracking that pybind11 requires.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ std::mutex g_registry_mutex;
|
|||||||
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
|
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
|
||||||
std::unordered_map<std::string, py::object> g_pending_package;
|
std::unordered_map<std::string, py::object> g_pending_package;
|
||||||
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
|
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
|
||||||
|
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
|
||||||
struct PluginInstanceHandle
|
struct PluginInstanceHandle
|
||||||
{
|
{
|
||||||
// The C++ plugin interface points into a Python object. Keep both alive through one
|
// The C++ plugin interface points into a Python object. Keep both alive through one
|
||||||
@@ -89,6 +90,7 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
|
|||||||
}
|
}
|
||||||
|
|
||||||
g_pending_fs_read_permissions.erase(plugin_key);
|
g_pending_fs_read_permissions.erase(plugin_key);
|
||||||
|
g_pending_network_permissions.erase(plugin_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -114,6 +116,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
|
|||||||
g_pending_capabilities.erase(plugin_key);
|
g_pending_capabilities.erase(plugin_key);
|
||||||
g_pending_package.erase(plugin_key);
|
g_pending_package.erase(plugin_key);
|
||||||
g_pending_fs_read_permissions.erase(plugin_key);
|
g_pending_fs_read_permissions.erase(plugin_key);
|
||||||
|
g_pending_network_permissions.erase(plugin_key);
|
||||||
}
|
}
|
||||||
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by
|
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by
|
||||||
// Python code on this thread are attributed to this plugin.
|
// Python code on this thread are attributed to this plugin.
|
||||||
@@ -145,6 +148,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
|||||||
g_pending_capabilities.erase(capture_key);
|
g_pending_capabilities.erase(capture_key);
|
||||||
g_pending_package.erase(capture_key);
|
g_pending_package.erase(capture_key);
|
||||||
g_pending_fs_read_permissions.erase(capture_key);
|
g_pending_fs_read_permissions.erase(capture_key);
|
||||||
|
g_pending_network_permissions.erase(capture_key);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +204,30 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> network_permissions;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||||
|
auto it = g_pending_network_permissions.find(capture_key);
|
||||||
|
if (it != g_pending_network_permissions.end()) {
|
||||||
|
network_permissions = std::move(it->second);
|
||||||
|
g_pending_network_permissions.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!network_permissions.empty()) {
|
||||||
|
bool granted = false;
|
||||||
|
{
|
||||||
|
py::gil_scoped_release release;
|
||||||
|
granted = PluginAuditManager::instance().request_network_permissions(plugin_key, network_permissions);
|
||||||
|
}
|
||||||
|
if (!granted) {
|
||||||
|
error = _u8L("Plugin network permission request denied");
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
||||||
|
discard_pending_for_key();
|
||||||
|
clear_active_key();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (py::error_already_set& err) {
|
} catch (py::error_already_set& err) {
|
||||||
log_python_exception_keep(err);
|
log_python_exception_keep(err);
|
||||||
error = err.what();
|
error = err.what();
|
||||||
@@ -323,6 +351,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
|
|||||||
g_pending_capabilities.erase(plugin_key);
|
g_pending_capabilities.erase(plugin_key);
|
||||||
g_pending_package.erase(plugin_key);
|
g_pending_package.erase(plugin_key);
|
||||||
g_pending_fs_read_permissions.erase(plugin_key);
|
g_pending_fs_read_permissions.erase(plugin_key);
|
||||||
|
g_pending_network_permissions.erase(plugin_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_active_plugin_key == plugin_key)
|
if (g_active_plugin_key == plugin_key)
|
||||||
@@ -350,6 +379,7 @@ void PythonPluginBridge::clear_pending_captures()
|
|||||||
}
|
}
|
||||||
g_pending_package.clear();
|
g_pending_package.clear();
|
||||||
g_pending_fs_read_permissions.clear();
|
g_pending_fs_read_permissions.clear();
|
||||||
|
g_pending_network_permissions.clear();
|
||||||
g_active_plugin_key.clear();
|
g_active_plugin_key.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -359,6 +389,7 @@ void PythonPluginBridge::clear_pending_captures()
|
|||||||
g_pending_capabilities.clear();
|
g_pending_capabilities.clear();
|
||||||
g_pending_package.clear();
|
g_pending_package.clear();
|
||||||
g_pending_fs_read_permissions.clear();
|
g_pending_fs_read_permissions.clear();
|
||||||
|
g_pending_network_permissions.clear();
|
||||||
g_active_plugin_key.clear();
|
g_active_plugin_key.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,24 +589,50 @@ void bind_python_api(pybind11::module_& m)
|
|||||||
|
|
||||||
m.def(
|
m.def(
|
||||||
"request_permissions",
|
"request_permissions",
|
||||||
[](const std::vector<std::string>& fs_read) {
|
[](const std::vector<std::string>& fs_read, const std::vector<std::string>& network) {
|
||||||
if (g_active_plugin_key.empty())
|
if (g_active_plugin_key.empty())
|
||||||
throw py::value_error("request_permissions() called outside plugin discovery context");
|
throw py::value_error("request_permissions() called outside plugin discovery context");
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
// Validate and canonicalize everything before touching the pending registry, so a
|
||||||
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
|
// partially invalid call cannot leave a half-recorded request behind.
|
||||||
|
std::vector<std::string> normalized_network;
|
||||||
|
normalized_network.reserve(network.size());
|
||||||
|
for (const std::string& url : network) {
|
||||||
|
const std::string normalized = PluginAuditManager::normalize_network_url(url);
|
||||||
|
if (normalized.empty())
|
||||||
|
throw py::value_error(
|
||||||
|
"request_permissions(network=...) requires an http:// or https:// URL with a host");
|
||||||
|
if (std::find(normalized_network.begin(), normalized_network.end(), normalized) == normalized_network.end())
|
||||||
|
normalized_network.push_back(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
for (const std::string& path : fs_read) {
|
for (const std::string& path : fs_read) {
|
||||||
if (path.empty())
|
if (path.empty())
|
||||||
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
|
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
|
||||||
if (std::find(requested.begin(), requested.end(), path) == requested.end())
|
}
|
||||||
requested.push_back(path);
|
|
||||||
|
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||||
|
auto& requested_fs = g_pending_fs_read_permissions[g_active_plugin_key];
|
||||||
|
for (const std::string& path : fs_read) {
|
||||||
|
if (std::find(requested_fs.begin(), requested_fs.end(), path) == requested_fs.end())
|
||||||
|
requested_fs.push_back(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized_network.empty()) {
|
||||||
|
auto& requested_network = g_pending_network_permissions[g_active_plugin_key];
|
||||||
|
for (const std::string& entry : normalized_network) {
|
||||||
|
if (std::find(requested_network.begin(), requested_network.end(), entry) == requested_network.end())
|
||||||
|
requested_network.push_back(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
py::arg("fs_read") = std::vector<std::string>{},
|
py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
|
||||||
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
|
R"pbdoc(Request permissions while OrcaSlicer loads your module.
|
||||||
|
|
||||||
The host presents the request to the user after register_capabilities() returns. Denying the
|
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
|
||||||
request aborts the plugin load.)pbdoc");
|
scheme and host, so one grant covers every path, query and port on that host. The host presents
|
||||||
|
the requests to the user after register_capabilities() returns. Denying a request aborts the
|
||||||
|
plugin load.)pbdoc");
|
||||||
|
|
||||||
m.def("plugin", [](py::object cls) {
|
m.def("plugin", [](py::object cls) {
|
||||||
if (g_active_plugin_key.empty())
|
if (g_active_plugin_key.empty())
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
#include "PluginHostUi.hpp"
|
#include "PluginHostUi.hpp"
|
||||||
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
#include <pybind11/cast.h>
|
#include <pybind11/cast.h>
|
||||||
#include <pybind11/pybind11.h>
|
#include <pybind11/pybind11.h>
|
||||||
@@ -12,21 +11,18 @@
|
|||||||
#include <libslic3r/Preset.hpp>
|
#include <libslic3r/Preset.hpp>
|
||||||
#include <libslic3r/PresetBundle.hpp>
|
#include <libslic3r/PresetBundle.hpp>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#include <slic3r/GUI/GUI.hpp>
|
#include <slic3r/GUI/GUI.hpp>
|
||||||
#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/Plater.hpp>
|
#include <slic3r/GUI/Plater.hpp>
|
||||||
#include <slic3r/GUI/PresetComboBoxes.hpp> // TabPresetComboBox::set_filament_idx
|
|
||||||
#include <slic3r/GUI/Tab.hpp>
|
#include <slic3r/GUI/Tab.hpp>
|
||||||
|
|
||||||
#include <pybind11/stl.h>
|
#include <pybind11/stl.h>
|
||||||
|
|
||||||
#include <wx/string.h>
|
|
||||||
|
|
||||||
#include <set>
|
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace py = pybind11;
|
namespace py = pybind11;
|
||||||
@@ -52,543 +48,6 @@ PresetCollection& printer_presets(PresetBundle& bundle)
|
|||||||
return static_cast<PresetCollection&>(bundle.printers);
|
return static_cast<PresetCollection&>(bundle.printers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle to one category of currently edited preset. It is a category, not a snapshot: it always
|
|
||||||
// resolves the preset the matching tab edits now, so it stays valid across preset switches.
|
|
||||||
struct PresetEditor
|
|
||||||
{
|
|
||||||
Preset::Type type = Preset::TYPE_INVALID;
|
|
||||||
};
|
|
||||||
|
|
||||||
const char* preset_tab_name(Preset::Type type)
|
|
||||||
{
|
|
||||||
switch (type) {
|
|
||||||
case Preset::TYPE_PRINT: return "process";
|
|
||||||
case Preset::TYPE_FILAMENT: return "filament";
|
|
||||||
case Preset::TYPE_PRINTER: return "printer";
|
|
||||||
default: return "preset";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PresetCollection& collection_for(PresetBundle& bundle, Preset::Type type)
|
|
||||||
{
|
|
||||||
switch (type) {
|
|
||||||
case Preset::TYPE_PRINT: return bundle.prints;
|
|
||||||
case Preset::TYPE_FILAMENT: return bundle.filaments;
|
|
||||||
case Preset::TYPE_PRINTER: return printer_presets(bundle);
|
|
||||||
default: throw std::invalid_argument("Unsupported preset category");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marker for a user-declined selection: Tab::select_preset() returned false. It derives from
|
|
||||||
// std::runtime_error (pybind11 maps the base to RuntimeError), so callers see it as an ordinary
|
|
||||||
// failed transaction and no staged setting or slot assignment is created.
|
|
||||||
struct SelectionCancelled : std::runtime_error
|
|
||||||
{
|
|
||||||
using std::runtime_error::runtime_error;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// The staged preset update lifecycle described in docs/HLSD/preset-plugin-api.md.
|
|
||||||
//
|
|
||||||
// begin_update() resolves and binds one target (selecting it through the normal UI flow when
|
|
||||||
// the caller asked for a preset that is not already active), returns a PresetTransaction that
|
|
||||||
// stages changes with set(), and applies them only on commit(). The transaction holds names,
|
|
||||||
// never preset pointers, so it stays valid across reloads; a target or filament slot that changed
|
|
||||||
// between begin_update() and commit() is rejected instead of silently editing the new mapping.
|
|
||||||
//
|
|
||||||
// begin_update() is synchronous: it runs on the background Script worker, marshals the target
|
|
||||||
// selection to the UI thread with PluginHostUi::run_on_ui_thread(), and blocks that worker until
|
|
||||||
// the selection and any unsaved-edit dialog settle. The UI thread stays responsive because the
|
|
||||||
// Script capability owns its worker thread. No GUI or preset pointer outlives the call; the
|
|
||||||
// returned transaction holds copied category/name/slot values. The update() shorthand shares the
|
|
||||||
// same target resolution and is safe only from that worker, never from the UI thread or a slicing
|
|
||||||
// pipeline hook.
|
|
||||||
//
|
|
||||||
// Known limits of this implementation, kept explicit rather than hidden:
|
|
||||||
// * A requested preset_name is resolved by the exact name its dropdown entry shows: the item's
|
|
||||||
// display label or its stored item alias (the internal preset name). A name two entries carry
|
|
||||||
// is rejected as ambiguous instead of being resolved by position. Process and printer resolve
|
|
||||||
// against the tab dropdown; a filament preset_name resolves against the requested slot's own
|
|
||||||
// sidebar selector, not the tab's, so its compatibility filter is the one that applies. A
|
|
||||||
// target the selector does not offer fails before the flow's unsaved-edit prompt. The flow
|
|
||||||
// itself is unchanged and the target is still confirmed as the edited preset afterwards.
|
|
||||||
// * A filament preset_name that names a different preset for a slot selects it through
|
|
||||||
// Tab::select_preset() (the normal flow, honouring unsaved-edit prompts) and then updates the
|
|
||||||
// slot mapping with PresetBundle::set_filament_preset() plus every dependent side effect the
|
|
||||||
// sidebar selection path performs: the project-dirty/selection refresh, the dynamic filament
|
|
||||||
// list, the slot's own combo (re-run so it lists and selects the assigned target), the
|
|
||||||
// conditional support-filament flushing recalc and the slot's AMS sync badge (read from the
|
|
||||||
// slot combo's own flag). The private Plater::priv::on_select_preset() path cannot be reused
|
|
||||||
// from here, so this mirrors its filament branch rather than calling it.
|
|
||||||
// * A user-declined selection does not undo decisions the user already accepted in an earlier
|
|
||||||
// dialog. It aborts before any slot assignment or transaction is created, so no staged
|
|
||||||
// setting is applied.
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Outcome of one committed transaction. Identifies the target, whether selecting it switched
|
|
||||||
// anything, and for filament (whose preset may be shared) which slots share the edited preset.
|
|
||||||
struct PresetUpdateResult
|
|
||||||
{
|
|
||||||
Preset::Type type = Preset::TYPE_INVALID;
|
|
||||||
std::string target_name;
|
|
||||||
bool selection_changed = false;
|
|
||||||
int slot_number = -1; // 1-based for filament, -1 otherwise
|
|
||||||
bool slot_assignment_changed = false;
|
|
||||||
std::vector<int> shared_slots;
|
|
||||||
std::vector<std::string> keys;
|
|
||||||
bool dirty = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct PresetTransaction
|
|
||||||
{
|
|
||||||
Preset::Type type = Preset::TYPE_INVALID;
|
|
||||||
std::string target_name;
|
|
||||||
bool filament = false;
|
|
||||||
int slot_number = 0; // 1-based, filament only
|
|
||||||
std::string slot_assignment_snapshot; // slot's assigned preset as of begin_update()
|
|
||||||
bool selection_changed = false;
|
|
||||||
bool slot_assignment_changed = false;
|
|
||||||
|
|
||||||
std::vector<std::pair<std::string, std::string>> staged;
|
|
||||||
std::set<std::string> staged_keys;
|
|
||||||
bool committed = false;
|
|
||||||
|
|
||||||
void set(const std::string& key, const std::string& value);
|
|
||||||
PresetUpdateResult commit();
|
|
||||||
};
|
|
||||||
|
|
||||||
GUI::GUI_App& require_gui_app()
|
|
||||||
{
|
|
||||||
GUI::GUI_App& app = GUI::wxGetApp();
|
|
||||||
if (app.is_closing())
|
|
||||||
throw std::runtime_error("OrcaSlicer is closing");
|
|
||||||
if (app.preset_bundle == nullptr)
|
|
||||||
throw std::runtime_error("Preset bundle is not available");
|
|
||||||
if (app.plater() == nullptr)
|
|
||||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
GUI::Tab* require_tab(GUI::GUI_App& app, Preset::Type type)
|
|
||||||
{
|
|
||||||
GUI::Tab* tab = app.get_tab(type);
|
|
||||||
if (tab == nullptr)
|
|
||||||
throw std::runtime_error(std::string("The ") + preset_tab_name(type) + " preset tab is not available");
|
|
||||||
// The tab for this preset type exists, but the selected printer's technology may not use it
|
|
||||||
// (the print tab no-ops under SLA); refuse instead of writing a value the UI cannot apply.
|
|
||||||
if (!tab->supports_printer_technology(app.preset_bundle->printers.get_selected_preset().printer_technology()))
|
|
||||||
throw std::runtime_error(std::string("The ") + preset_tab_name(type)
|
|
||||||
+ " preset is not available for the selected printer technology");
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
// True for a combo row that is a group header, a marker or a disabled row: it carries a marker in
|
|
||||||
// its client data and is not a selectable preset.
|
|
||||||
bool combo_item_is_marker(GUI::PresetComboBox& combo, int item)
|
|
||||||
{
|
|
||||||
const auto marker = reinterpret_cast<std::size_t>(combo.GetClientData(item));
|
|
||||||
return marker >= GUI::PresetComboBox::LABEL_ITEM_DISABLED && marker < GUI::PresetComboBox::LABEL_ITEM_MAX;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How a caller-supplied name resolved against a combo's actual items.
|
|
||||||
struct ComboPresetMatch
|
|
||||||
{
|
|
||||||
int item = -1; // first matching item, or -1
|
|
||||||
bool ambiguous = false; // two distinct items carry this name
|
|
||||||
std::string internal_name; // internal preset name of the match (empty when none)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve `requested` by the name each combo item shows: its display label, or its stored item
|
|
||||||
// alias (the internal preset name, or the printer model for a system printer preset). Names are
|
|
||||||
// compared with the modified-dirty suffix removed, i.e. exactly what the dropdown shows. Reading
|
|
||||||
// the combo's own items means this respects the combo's real selectable model (visibility, its
|
|
||||||
// show-incompatible state and, for a slot combo, that slot's compatibility). A name that two
|
|
||||||
// distinct items carry is reported ambiguous instead of being resolved by position.
|
|
||||||
ComboPresetMatch resolve_combo_preset(GUI::PresetComboBox& combo, const std::string& requested)
|
|
||||||
{
|
|
||||||
ComboPresetMatch match;
|
|
||||||
const std::string want = Preset::remove_suffix_modified(requested);
|
|
||||||
const int item_count = combo.GetCount();
|
|
||||||
for (int item = 0; item < item_count; ++item) {
|
|
||||||
if (combo_item_is_marker(combo, item))
|
|
||||||
continue;
|
|
||||||
const wxString alias = combo.GetItemAlias(item);
|
|
||||||
const std::string alias_name = alias.empty() ? std::string()
|
|
||||||
: Preset::remove_suffix_modified(alias.ToUTF8().data());
|
|
||||||
const std::string label_name = Preset::remove_suffix_modified(combo.GetString(item).ToUTF8().data());
|
|
||||||
if ((alias_name.empty() || alias_name != want) && label_name != want)
|
|
||||||
continue;
|
|
||||||
if (match.item >= 0 && match.item != item) {
|
|
||||||
match.ambiguous = true;
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
match.item = item;
|
|
||||||
match.internal_name = alias_name.empty() ? label_name : alias_name;
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Index of the item whose internal name is `preset_name`, or -1. Used for per-item flags (the AMS
|
|
||||||
// sync badge); the internal name is unique, so no ambiguity handling is needed here.
|
|
||||||
int combo_item_for_preset(GUI::PresetComboBox& combo, const std::string& preset_name)
|
|
||||||
{
|
|
||||||
const std::string want = Preset::remove_suffix_modified(preset_name);
|
|
||||||
const int item_count = combo.GetCount();
|
|
||||||
for (int item = 0; item < item_count; ++item) {
|
|
||||||
if (combo_item_is_marker(combo, item))
|
|
||||||
continue;
|
|
||||||
const wxString alias = combo.GetItemAlias(item);
|
|
||||||
const std::string alias_name = alias.empty() ? std::string()
|
|
||||||
: Preset::remove_suffix_modified(alias.ToUTF8().data());
|
|
||||||
if (!alias_name.empty() && alias_name == want)
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve `requested` to the internal preset name its dropdown entry names, or throw. `category`
|
|
||||||
// and `selector` are used only for the error text. A name the selector does not offer, or that two
|
|
||||||
// entries share, raises std::invalid_argument.
|
|
||||||
std::string resolve_preset_in_combo(GUI::PresetComboBox& combo, const std::string& requested,
|
|
||||||
const std::string& category, const std::string& selector)
|
|
||||||
{
|
|
||||||
const ComboPresetMatch match = resolve_combo_preset(combo, requested);
|
|
||||||
if (match.ambiguous)
|
|
||||||
throw std::invalid_argument("The " + category + " name \"" + requested + "\" is ambiguous in the " + selector
|
|
||||||
+ "; more than one entry shows it, so name the preset by its own entry");
|
|
||||||
if (match.item < 0)
|
|
||||||
throw std::invalid_argument("The " + category + " preset \"" + requested + "\" is not offered by the " + selector);
|
|
||||||
return match.internal_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The preset a filament slot currently resolves to, by its internal name. A slot whose stored
|
|
||||||
// name is empty or does not resolve yields an error identifying the slot.
|
|
||||||
std::string resolved_slot_preset(PresetBundle& bundle, int slot_number)
|
|
||||||
{
|
|
||||||
if (slot_number < 1 || static_cast<size_t>(slot_number) > bundle.filament_presets.size())
|
|
||||||
throw std::out_of_range("Filament slot " + std::to_string(slot_number)
|
|
||||||
+ " is out of range; slot numbers are 1-based and this printer has "
|
|
||||||
+ std::to_string(bundle.filament_presets.size()) + " filament slot(s)");
|
|
||||||
const std::string& assigned_name = bundle.filament_presets[static_cast<size_t>(slot_number) - 1];
|
|
||||||
if (assigned_name.empty())
|
|
||||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset");
|
|
||||||
Preset* preset = bundle.filaments.find_preset(assigned_name);
|
|
||||||
if (preset == nullptr)
|
|
||||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " is assigned \"" + assigned_name
|
|
||||||
+ "\", which does not resolve to a filament preset");
|
|
||||||
return preset->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
void PresetTransaction::set(const std::string& key, const std::string& value)
|
|
||||||
{
|
|
||||||
if (committed)
|
|
||||||
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
|
|
||||||
if (key.empty())
|
|
||||||
throw std::invalid_argument("A setting key is required");
|
|
||||||
// Conflicting duplicate writes to one setting are rejected rather than resolved by order.
|
|
||||||
if (!staged_keys.insert(key).second)
|
|
||||||
throw std::invalid_argument("Setting \"" + key + "\" is already staged in this transaction; "
|
|
||||||
"conflicting duplicate writes to one setting are rejected");
|
|
||||||
staged.emplace_back(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
PresetUpdateResult PresetTransaction::commit()
|
|
||||||
{
|
|
||||||
if (committed)
|
|
||||||
throw std::runtime_error("This preset transaction has already been committed; start a new begin_update()");
|
|
||||||
if (type == Preset::TYPE_INVALID)
|
|
||||||
throw std::runtime_error("This preset transaction has no target");
|
|
||||||
|
|
||||||
PresetUpdateResult result;
|
|
||||||
PluginHostUi::run_on_ui_thread([&] {
|
|
||||||
GUI::GUI_App& app = require_gui_app();
|
|
||||||
GUI::Tab* tab = require_tab(app, type);
|
|
||||||
PresetBundle& bundle = *app.preset_bundle;
|
|
||||||
PresetCollection& collection = collection_for(bundle, type);
|
|
||||||
|
|
||||||
// The target must still be the edited preset: the user may have switched away while the
|
|
||||||
// transaction was staged, and editing a different preset would break "explicit target".
|
|
||||||
const std::string edited = collection.get_edited_preset().name;
|
|
||||||
if (edited != target_name)
|
|
||||||
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \"" + edited
|
|
||||||
+ "\", not this transaction's target \"" + target_name
|
|
||||||
+ "\"; nothing was applied");
|
|
||||||
|
|
||||||
if (filament) {
|
|
||||||
// Reject a stale slot mapping instead of editing whatever the slot now points at.
|
|
||||||
const std::string now_assigned = resolved_slot_preset(bundle, slot_number);
|
|
||||||
if (now_assigned != slot_assignment_snapshot)
|
|
||||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " now holds \"" + now_assigned
|
|
||||||
+ "\", not \"" + slot_assignment_snapshot
|
|
||||||
+ "\" as when this transaction was created; the slot mapping changed, so nothing was applied");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the whole batch through the tab's batch path: it validates every key and value before
|
|
||||||
// touching the preset, rejects duplicate keys, runs the same per-setting commit path a hand
|
|
||||||
// edit uses (validation, on_change, dirty state) and refreshes once for the batch. A failure
|
|
||||||
// restores the edited preset, so no staged setting survives a failed commit.
|
|
||||||
tab->apply_preset_config_values(staged);
|
|
||||||
|
|
||||||
result.type = type;
|
|
||||||
result.target_name = target_name;
|
|
||||||
result.selection_changed = selection_changed;
|
|
||||||
result.slot_number = filament ? slot_number : -1;
|
|
||||||
result.slot_assignment_changed = slot_assignment_changed;
|
|
||||||
result.keys.reserve(staged.size());
|
|
||||||
for (const auto& entry : staged)
|
|
||||||
result.keys.push_back(entry.first);
|
|
||||||
result.dirty = collection.get_edited_preset().is_dirty;
|
|
||||||
|
|
||||||
if (filament) {
|
|
||||||
// Every slot whose assigned preset resolves to the edited one is affected: the preset
|
|
||||||
// is shared, not a per-slot override.
|
|
||||||
for (std::size_t index = 0; index < bundle.filament_presets.size(); ++index) {
|
|
||||||
Preset* preset = bundle.filaments.find_preset(bundle.filament_presets[index]);
|
|
||||||
if (preset != nullptr && preset->name == target_name)
|
|
||||||
result.shared_slots.push_back(static_cast<int>(index) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
committed = true;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process/printer target resolution: the active preset when no name is given, otherwise the named
|
|
||||||
// preset selected through the normal tab-selection flow (which enforces the dropdown/compatibility
|
|
||||||
// rules and the unsaved-edit prompt). Binds only after confirming the requested preset is edited.
|
|
||||||
// Runs on the UI thread with the GIL released, called from begin_preset_update().
|
|
||||||
void bind_preset_target(Preset::Type type, const std::string& preset_name, PresetTransaction& transaction)
|
|
||||||
{
|
|
||||||
GUI::GUI_App& app = require_gui_app();
|
|
||||||
GUI::Tab* tab = require_tab(app, type);
|
|
||||||
PresetCollection& collection = collection_for(*app.preset_bundle, type);
|
|
||||||
|
|
||||||
const std::string current = collection.get_edited_preset().name;
|
|
||||||
std::string target = current;
|
|
||||||
bool selection_changed = false;
|
|
||||||
|
|
||||||
if (!preset_name.empty()) {
|
|
||||||
// Resolve by the name the tab's dropdown actually shows (display label or item alias), so a
|
|
||||||
// plugin can pass exactly what the user sees. With no populated dropdown there is nothing
|
|
||||||
// to resolve against, so fall back to collection internal names.
|
|
||||||
GUI::TabPresetComboBox* combo = tab->get_combo_box();
|
|
||||||
std::string resolved;
|
|
||||||
if (combo != nullptr && combo->GetCount() > 0)
|
|
||||||
resolved = resolve_preset_in_combo(*combo, preset_name, preset_tab_name(type),
|
|
||||||
"preset selector for the selected printer");
|
|
||||||
else
|
|
||||||
resolved = preset_name;
|
|
||||||
|
|
||||||
Preset* requested = collection.find_preset(resolved);
|
|
||||||
if (requested == nullptr)
|
|
||||||
throw std::invalid_argument("No " + std::string(preset_tab_name(type)) + " preset named \"" + resolved
|
|
||||||
+ "\" is available to select");
|
|
||||||
target = requested->name;
|
|
||||||
if (target != current) {
|
|
||||||
// Normal UI selection: honours the unsaved-edit prompt; a declined prompt returns false
|
|
||||||
// and is a user cancellation, distinct from any other resolution failure.
|
|
||||||
if (!tab->select_preset(target))
|
|
||||||
throw SelectionCancelled("Switching the edited " + std::string(preset_tab_name(type)) + " preset to \""
|
|
||||||
+ target + "\" was canceled; nothing was staged");
|
|
||||||
selection_changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// select_preset() can still leave a different preset edited (e.g. the target is not an
|
|
||||||
// allowed dropdown choice), so confirm before binding.
|
|
||||||
if (collection.get_edited_preset().name != target)
|
|
||||||
throw std::runtime_error("The edited " + std::string(preset_tab_name(type)) + " preset is \""
|
|
||||||
+ collection.get_edited_preset().name + "\", not \"" + target
|
|
||||||
+ "\"; the transaction was not created");
|
|
||||||
|
|
||||||
transaction.type = type;
|
|
||||||
transaction.target_name = target;
|
|
||||||
transaction.selection_changed = selection_changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blocking form used by begin_update() and update().
|
|
||||||
PresetTransaction begin_preset_update(Preset::Type type, const std::string& preset_name)
|
|
||||||
{
|
|
||||||
PresetTransaction transaction;
|
|
||||||
PluginHostUi::run_on_ui_thread([&] { bind_preset_target(type, preset_name, transaction); });
|
|
||||||
return transaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filament target resolution: slot_number identifies the slot; an omitted preset_name keeps the
|
|
||||||
// preset assigned to it. A supplied preset_name selects that preset for the slot through the normal
|
|
||||||
// tab-selection flow, then records the slot assignment and its dependent Plater side effects (the
|
|
||||||
// ones the sidebar selection path performs) only after that flow succeeds, so a canceled prompt
|
|
||||||
// changes neither. A mixed-color virtual slot is rejected before any of this, with or without a
|
|
||||||
// preset_name. A requested preset the requested slot's own dropdown does not offer is rejected
|
|
||||||
// before the selection flow, so no dialog is raised for it; the one exception is the slot's own
|
|
||||||
// current assignment, which stays targetable when the dropdown hides it. A different preset the
|
|
||||||
// tab happens to edit is a globally edited target, not an offered choice for this slot, and is
|
|
||||||
// rejected.
|
|
||||||
// Runs on the UI thread with the GIL released, called from begin_filament_update().
|
|
||||||
void bind_filament_target(int slot_number, const std::string& preset_name, PresetTransaction& transaction)
|
|
||||||
{
|
|
||||||
GUI::GUI_App& app = require_gui_app();
|
|
||||||
PresetBundle& bundle = *app.preset_bundle;
|
|
||||||
GUI::Plater* plater = app.plater();
|
|
||||||
|
|
||||||
// A mixed-color virtual slot has no single filament preset behind it, so every form of this
|
|
||||||
// call is rejected: with or without a preset_name there is no one preset to edit or assign.
|
|
||||||
// Range-guarded so an out-of-range slot still gets resolved_slot_preset()'s clear error.
|
|
||||||
if (slot_number >= 1 && static_cast<size_t>(slot_number) <= bundle.filament_presets.size()
|
|
||||||
&& bundle.is_mixed_filament(static_cast<size_t>(slot_number) - 1))
|
|
||||||
throw std::invalid_argument("Filament slot " + std::to_string(slot_number)
|
|
||||||
+ " is a mixed-color virtual slot; it has no single filament preset to edit or assign");
|
|
||||||
|
|
||||||
// Resolve and validate before any selection, so a bad slot fails without a prompt.
|
|
||||||
const std::string assigned_internal = resolved_slot_preset(bundle, slot_number);
|
|
||||||
|
|
||||||
GUI::Tab* tab = require_tab(app, Preset::TYPE_FILAMENT);
|
|
||||||
|
|
||||||
const size_t slot_index = static_cast<size_t>(slot_number) - 1;
|
|
||||||
|
|
||||||
std::string target = assigned_internal;
|
|
||||||
if (!preset_name.empty()) {
|
|
||||||
// Resolve against the requested sidebar slot's own selector, not the tab's: that is the
|
|
||||||
// model the user picks this slot's filament from, including its compatibility filter. The
|
|
||||||
// selector matches the exact name shown (display label or item alias).
|
|
||||||
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
|
|
||||||
GUI::PlaterPresetComboBox* slot_combo = slot_index < slot_combos.size() ? slot_combos[slot_index] : nullptr;
|
|
||||||
std::string resolved;
|
|
||||||
if (slot_combo != nullptr && slot_combo->GetCount() > 0) {
|
|
||||||
const ComboPresetMatch match = resolve_combo_preset(*slot_combo, preset_name);
|
|
||||||
if (match.ambiguous)
|
|
||||||
throw std::invalid_argument("The filament name \"" + preset_name
|
|
||||||
+ "\" is ambiguous in the filament preset selector for filament slot "
|
|
||||||
+ std::to_string(slot_number)
|
|
||||||
+ "; more than one entry shows it, so name the preset by its own entry");
|
|
||||||
if (match.item >= 0) {
|
|
||||||
resolved = match.internal_name;
|
|
||||||
} else {
|
|
||||||
// Not shown by this slot's selector. Only the slot's current assignment stays
|
|
||||||
// targetable (a hidden or incompatible-but-selected preset the dropdown omits);
|
|
||||||
// resolve it by internal name or by display alias and accept only that preset. A
|
|
||||||
// different preset the tab happens to edit is a globally edited target, not an
|
|
||||||
// offered choice for this slot, and is rejected.
|
|
||||||
Preset* by_name = bundle.filaments.find_preset(preset_name);
|
|
||||||
if (by_name == nullptr) {
|
|
||||||
const std::string& alias_name = bundle.filaments.get_preset_name_by_alias(preset_name);
|
|
||||||
by_name = bundle.filaments.find_preset(alias_name);
|
|
||||||
}
|
|
||||||
if (by_name == nullptr || by_name->name != assigned_internal)
|
|
||||||
throw std::invalid_argument("Filament preset \"" + preset_name
|
|
||||||
+ "\" is not offered by the filament preset selector for filament slot "
|
|
||||||
+ std::to_string(slot_number));
|
|
||||||
resolved = by_name->name;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// The slot selector is not populated; fall back to the tab selector, then to collection
|
|
||||||
// internal names, so the call still works when the sidebar has not been laid out.
|
|
||||||
GUI::TabPresetComboBox* tab_combo = tab->get_combo_box();
|
|
||||||
if (tab_combo != nullptr && tab_combo->GetCount() > 0)
|
|
||||||
resolved = resolve_preset_in_combo(*tab_combo, preset_name, "filament",
|
|
||||||
"filament preset selector for the selected printer");
|
|
||||||
else
|
|
||||||
resolved = preset_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
Preset* requested = bundle.filaments.find_preset(resolved);
|
|
||||||
if (requested == nullptr)
|
|
||||||
throw std::invalid_argument("No filament preset named \"" + resolved + "\" is available to select");
|
|
||||||
target = requested->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool selection_changed = false;
|
|
||||||
if (bundle.filaments.get_edited_preset().name != target) {
|
|
||||||
// A declined prompt is a user cancellation: it is thrown before any slot-assignment change,
|
|
||||||
// so neither the mapping nor a setting is touched.
|
|
||||||
if (!tab->select_preset(target))
|
|
||||||
throw SelectionCancelled("Switching the edited filament preset to \"" + target + "\" for filament slot "
|
|
||||||
+ std::to_string(slot_number)
|
|
||||||
+ " was canceled; neither the slot assignment nor any setting was changed");
|
|
||||||
if (bundle.filaments.get_edited_preset().name != target)
|
|
||||||
throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name
|
|
||||||
+ "\", not \"" + target + "\"; the transaction was not created");
|
|
||||||
selection_changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool assignment_changed = false;
|
|
||||||
if (!preset_name.empty() && target != assigned_internal) {
|
|
||||||
if (app.app_config == nullptr)
|
|
||||||
throw std::runtime_error("Application configuration is not available");
|
|
||||||
// Mirror the filament branch of Plater::priv::on_select_preset() (the sidebar's normal
|
|
||||||
// preset selection): update the mapping, then every dependent project/UI side effect,
|
|
||||||
// rather than the vector entry alone.
|
|
||||||
const bool was_support = GUI::is_support_filament(static_cast<int>(slot_index));
|
|
||||||
bundle.set_filament_preset(slot_index, target);
|
|
||||||
plater->update_project_dirty_from_presets();
|
|
||||||
bundle.export_selections(*app.app_config);
|
|
||||||
plater->sidebar().update_dynamic_filament_list();
|
|
||||||
// Assigning a support filament (or moving away from one) changes which filaments the
|
|
||||||
// flush rules apply to, so the normal flow recalculates the flushing volumes when
|
|
||||||
// auto-calc covers every filament.
|
|
||||||
if (GUI::is_support_filament(static_cast<int>(slot_index)) != was_support
|
|
||||||
&& app.app_config->get("auto_calculate_flush") == "all")
|
|
||||||
plater->sidebar().auto_calc_flushing_volumes(static_cast<int>(slot_index));
|
|
||||||
// Refresh the requested slot's own sidebar combo so its list and current selection show the
|
|
||||||
// newly assigned target (the list may not have contained it), matching the sidebar
|
|
||||||
// selection path. Fetch the pointer after update_dynamic_filament_list(), which may
|
|
||||||
// rebuild the combo vector.
|
|
||||||
std::vector<GUI::PlaterPresetComboBox*>& slot_combos = plater->sidebar().combos_filament();
|
|
||||||
if (slot_index < slot_combos.size() && slot_combos[slot_index] != nullptr) {
|
|
||||||
GUI::PlaterPresetComboBox* slot_combo = slot_combos[slot_index];
|
|
||||||
slot_combo->update();
|
|
||||||
// Keep the slot's AMS sync badge truthful, reading the item's own flag, the same source
|
|
||||||
// on_select_preset() reads, so no UI state is invented here.
|
|
||||||
const int item = combo_item_for_preset(*slot_combo, target);
|
|
||||||
if (item >= 0)
|
|
||||||
slot_combo->set_sync_badge(slot_combo->GetFlag(item)
|
|
||||||
== static_cast<int>(GUI::PresetComboBox::FilamentAMSType::FROM_AMS));
|
|
||||||
}
|
|
||||||
plater->on_filament_change(slot_index);
|
|
||||||
assignment_changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match PlaterPresetComboBox::switch_to_tab(): record which 0-based filament slot this tab
|
|
||||||
// combobox now edits, so the GUI shows the slot the value was written for.
|
|
||||||
if (GUI::TabPresetComboBox* combo = tab->get_combo_box())
|
|
||||||
combo->set_filament_idx(slot_number - 1);
|
|
||||||
|
|
||||||
transaction.type = Preset::TYPE_FILAMENT;
|
|
||||||
transaction.filament = true;
|
|
||||||
transaction.slot_number = slot_number;
|
|
||||||
transaction.target_name = target;
|
|
||||||
transaction.selection_changed = selection_changed;
|
|
||||||
transaction.slot_assignment_changed = assignment_changed;
|
|
||||||
// Snapshot the mapping after the explicit assignment, as the slot's resolved internal name,
|
|
||||||
// so commit() compares like with like (a stored alias resolves to the same internal preset).
|
|
||||||
transaction.slot_assignment_snapshot = resolved_slot_preset(bundle, slot_number);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blocking form used by begin_update() and update().
|
|
||||||
PresetTransaction begin_filament_update(int slot_number, const std::string& preset_name)
|
|
||||||
{
|
|
||||||
PresetTransaction transaction;
|
|
||||||
PluginHostUi::run_on_ui_thread([&] { bind_filament_target(slot_number, preset_name, transaction); });
|
|
||||||
return transaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compatibility shorthand: one staged setting, committed immediately through the same
|
|
||||||
// begin_update()/set()/commit() path, so targeting, selection, validation and commit are shared.
|
|
||||||
void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value)
|
|
||||||
{
|
|
||||||
PresetTransaction transaction = begin_preset_update(type, std::string());
|
|
||||||
transaction.set(key, value);
|
|
||||||
transaction.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
void update_filament_slot(int slot_number, const std::string& key, const std::string& value)
|
|
||||||
{
|
|
||||||
PresetTransaction transaction = begin_filament_update(slot_number, std::string());
|
|
||||||
transaction.set(key, value);
|
|
||||||
transaction.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
void reload_local_bundle(const std::string& bundle_id)
|
void reload_local_bundle(const std::string& bundle_id)
|
||||||
{
|
{
|
||||||
bool reloaded = false;
|
bool reloaded = false;
|
||||||
@@ -641,7 +100,7 @@ std::vector<std::string> list_bundle_ids()
|
|||||||
|
|
||||||
void host_bindings::register_presets(py::module_& host)
|
void host_bindings::register_presets(py::module_& host)
|
||||||
{
|
{
|
||||||
auto preset = host.def_submodule("preset", "Preset operations");
|
auto preset = host.def_submodule("preset", "Preset operations");
|
||||||
auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
|
auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
|
||||||
bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
|
bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
|
||||||
"Reload one local preset bundle from the active user's _local directory.");
|
"Reload one local preset bundle from the active user's _local directory.");
|
||||||
@@ -660,145 +119,6 @@ void host_bindings::register_presets(py::module_& host)
|
|||||||
.value("Plate", Preset::TYPE_PLATE)
|
.value("Plate", Preset::TYPE_PLATE)
|
||||||
.value("Model", Preset::TYPE_MODEL);
|
.value("Model", Preset::TYPE_MODEL);
|
||||||
|
|
||||||
py::class_<PresetUpdateResult>(preset, "PresetUpdateResult",
|
|
||||||
"Outcome of one committed preset transaction. target_name is the preset that was "
|
|
||||||
"edited; selection_changed says whether begin_update() switched the active preset; "
|
|
||||||
"for filament, slot_number is the 1-based target slot, slot_assignment_changed "
|
|
||||||
"says whether selecting the requested preset reassigned that slot, and shared_slots "
|
|
||||||
"lists every 1-based slot whose assigned preset is the edited (shared) one. keys "
|
|
||||||
"lists the settings applied, in order, and dirty says whether the edited preset "
|
|
||||||
"now differs from its saved state.")
|
|
||||||
.def_readonly("type", &PresetUpdateResult::type)
|
|
||||||
.def_readonly("target_name", &PresetUpdateResult::target_name)
|
|
||||||
.def_readonly("selection_changed", &PresetUpdateResult::selection_changed)
|
|
||||||
.def_readonly("slot_number", &PresetUpdateResult::slot_number)
|
|
||||||
.def_readonly("slot_assignment_changed", &PresetUpdateResult::slot_assignment_changed)
|
|
||||||
.def_readonly("shared_slots", &PresetUpdateResult::shared_slots)
|
|
||||||
.def_readonly("keys", &PresetUpdateResult::keys)
|
|
||||||
.def_readonly("dirty", &PresetUpdateResult::dirty)
|
|
||||||
.def_property_readonly("category", [](const PresetUpdateResult& result) { return preset_tab_name(result.type); })
|
|
||||||
.def("__repr__", [](const PresetUpdateResult& result) {
|
|
||||||
return std::string("<PresetUpdateResult ") + preset_tab_name(result.type) + " \"" + result.target_name
|
|
||||||
+ "\" keys=" + std::to_string(result.keys.size()) + ">";
|
|
||||||
});
|
|
||||||
|
|
||||||
py::class_<PresetTransaction>(preset, "PresetTransaction",
|
|
||||||
"A staged, single-target preset edit returned by begin_update(). set() stages a "
|
|
||||||
"serialized value without mutating the preset; commit() validates and applies the "
|
|
||||||
"whole batch through the normal setting-change path. begin_update() blocks the "
|
|
||||||
"background Script worker until any selection or unsaved-edit dialog is answered, "
|
|
||||||
"while the UI thread stays responsive; set() and commit() run synchronously on the "
|
|
||||||
"same worker. Nothing is saved to disk: the edited preset stays dirty until the user "
|
|
||||||
"saves it.")
|
|
||||||
.def_readonly("type", &PresetTransaction::type)
|
|
||||||
.def_readonly("target_name", &PresetTransaction::target_name)
|
|
||||||
.def_readonly("selection_changed", &PresetTransaction::selection_changed)
|
|
||||||
.def_property_readonly("slot_number", [](const PresetTransaction& transaction) {
|
|
||||||
return transaction.filament ? transaction.slot_number : -1;
|
|
||||||
})
|
|
||||||
.def_property_readonly("category", [](const PresetTransaction& transaction) { return preset_tab_name(transaction.type); })
|
|
||||||
.def_property_readonly("staged_keys", [](const PresetTransaction& transaction) {
|
|
||||||
std::vector<std::string> keys;
|
|
||||||
keys.reserve(transaction.staged.size());
|
|
||||||
for (const auto& entry : transaction.staged)
|
|
||||||
keys.push_back(entry.first);
|
|
||||||
return keys;
|
|
||||||
})
|
|
||||||
.def("set", &PresetTransaction::set, py::arg("key"), py::arg("value"),
|
|
||||||
"Stage one setting change. key must be a setting of the target preset category and value its "
|
|
||||||
"serialized string form; staging validates neither, so commit() is where an invalid key or value "
|
|
||||||
"fails. Staging the same key twice in one transaction raises ValueError (conflicting duplicate "
|
|
||||||
"writes are rejected). After commit() the transaction is closed.")
|
|
||||||
.def("commit", &PresetTransaction::commit,
|
|
||||||
"Validate and apply every staged setting through the same path as editing it in the GUI, then return "
|
|
||||||
"a PresetUpdateResult. An invalid key or value raises ValueError; a changed target or filament slot "
|
|
||||||
"mapping raises RuntimeError; a failure leaves none of the staged settings applied. The edited preset "
|
|
||||||
"becomes dirty until the user saves it.");
|
|
||||||
|
|
||||||
py::class_<PresetEditor>(preset, "PresetEditor",
|
|
||||||
"Handle to a category of currently edited preset. It is not a snapshot: it always "
|
|
||||||
"resolves the preset the matching tab edits now, so it stays usable across preset "
|
|
||||||
"switches. update() edits the preset in place; the change is not saved to disk.")
|
|
||||||
.def_readonly("type", &PresetEditor::type)
|
|
||||||
.def(
|
|
||||||
"begin_update",
|
|
||||||
[](const PresetEditor& editor, const std::string& preset_name) {
|
|
||||||
if (editor.type == Preset::TYPE_FILAMENT)
|
|
||||||
throw std::invalid_argument("begin_update on the filament editor takes "
|
|
||||||
"(slot_number, preset_name=\"\"); use orca.host.preset.filament.begin_update(slot_number, ...)");
|
|
||||||
return begin_preset_update(editor.type, preset_name);
|
|
||||||
},
|
|
||||||
py::arg("preset_name") = "",
|
|
||||||
"Resolve and bind this category's target preset and return a PresetTransaction. With no preset_name the "
|
|
||||||
"currently active preset is targeted; a preset_name must be a preset this category can select, and is "
|
|
||||||
"selected through the normal preset-selection flow (which prompts about unsaved edits). The call blocks "
|
|
||||||
"the background Script worker until any such dialog is answered, leaving the UI thread responsive. A "
|
|
||||||
"selection the user cancels raises RuntimeError; an unavailable or invalid target raises ValueError. "
|
|
||||||
"Either failure creates no transaction and stages no setting. Call it from the background Script worker, "
|
|
||||||
"never from the UI thread. This form is for process and printer; filament takes "
|
|
||||||
"begin_update(slot_number, preset_name=\"\").")
|
|
||||||
.def(
|
|
||||||
"begin_update",
|
|
||||||
[](const PresetEditor& editor, int slot_number, const std::string& preset_name) {
|
|
||||||
if (editor.type != Preset::TYPE_FILAMENT)
|
|
||||||
throw std::invalid_argument("The slot_number form of begin_update() is only available on "
|
|
||||||
"orca.host.preset.filament; call begin_update(preset_name=\"\") on this editor");
|
|
||||||
return begin_filament_update(slot_number, preset_name);
|
|
||||||
},
|
|
||||||
py::arg("slot_number"), py::arg("preset_name") = "",
|
|
||||||
"Resolve and bind a filament preset by 1-based slot and return a PresetTransaction. Omitting preset_name "
|
|
||||||
"targets the preset assigned to the slot; supplying a different name selects it for the slot through the "
|
|
||||||
"normal flow (prompting about unsaved edits) and, once that succeeds, records the new slot assignment with "
|
|
||||||
"the same Plater side effects the sidebar selection performs (project/selection refresh, dynamic filament "
|
|
||||||
"list, conditional support-filament flushing recalc and the slot's AMS sync badge). A requested preset "
|
|
||||||
"this slot's dropdown does not offer is rejected before that flow, so it raises no prompt; the slot's own "
|
|
||||||
"current assignment stays targetable even when the dropdown hides it. A selection the user cancels raises "
|
|
||||||
"RuntimeError and changes neither the slot assignment nor any setting. An out-of-range, empty or "
|
|
||||||
"unresolved slot is rejected, and a mixed-color virtual slot is rejected whether or not preset_name is "
|
|
||||||
"given. The transaction edits the shared preset, so every slot assigned to it is affected. The call "
|
|
||||||
"blocks the background Script worker until the selection settles, leaving the UI thread responsive; call "
|
|
||||||
"it from that worker, never from the UI thread.")
|
|
||||||
.def(
|
|
||||||
"update",
|
|
||||||
[](const PresetEditor& editor, const std::string& key, const std::string& value) {
|
|
||||||
update_edited_preset(editor.type, key, value);
|
|
||||||
},
|
|
||||||
py::arg("key"), py::arg("value"),
|
|
||||||
"Set one setting of the currently edited preset and run the same committed-change path as "
|
|
||||||
"editing the setting in the GUI; the edited preset becomes dirty until saved/exported. "
|
|
||||||
"key must be a setting of this preset category and value its serialized string form, "
|
|
||||||
"otherwise ValueError is raised and nothing is written. A setting edited through a field "
|
|
||||||
"runs that field's validation even before its page has been opened; one the GUI edits "
|
|
||||||
"outside a field (such as compatible printers) also recomputes preset compatibility. "
|
|
||||||
"Equivalent to begin_update() with the active preset as target, then set(key, value) "
|
|
||||||
"and commit(); like begin_update() this call blocks the background Script worker.")
|
|
||||||
.def(
|
|
||||||
"update",
|
|
||||||
[](const PresetEditor& editor, int slot_number, const std::string& key, const std::string& value) {
|
|
||||||
if (editor.type != Preset::TYPE_FILAMENT)
|
|
||||||
throw std::invalid_argument("The slot_number form of update() is only available on "
|
|
||||||
"orca.host.preset.filament; call update(key, value) on this editor");
|
|
||||||
update_filament_slot(slot_number, key, value);
|
|
||||||
},
|
|
||||||
py::arg("slot_number"), py::arg("key"), py::arg("value"),
|
|
||||||
"Set one setting of the filament preset assigned to a 1-based filament slot. When that preset "
|
|
||||||
"is not the one currently edited the call switches to it through the normal preset-selection "
|
|
||||||
"flow, so an unsaved edited preset still prompts the user and a declined prompt cancels the "
|
|
||||||
"call with an error and no change. An out-of-range, empty or unresolved slot is rejected with "
|
|
||||||
"a clear error, as is a mixed-color virtual slot, which has no single preset to edit. Other "
|
|
||||||
"preset editors accept only update(key, value); this form raises "
|
|
||||||
"ValueError on them. Equivalent to begin_update(slot_number) with an omitted preset_name "
|
|
||||||
"(the slot's current assignment is kept), then set(key, value) and commit(); like "
|
|
||||||
"begin_update() this call blocks the background Script worker.");
|
|
||||||
|
|
||||||
// The three preset categories edited by the main tabs. `process` is the print/process preset,
|
|
||||||
// `printer` the machine preset and `filament` the currently edited filament preset (its
|
|
||||||
// 3-argument form addresses a 1-based filament slot); each only accepts its own settings, so a
|
|
||||||
// printer setting passed to `process` is rejected instead of written silently.
|
|
||||||
preset.attr("process") = py::cast(PresetEditor{Preset::TYPE_PRINT});
|
|
||||||
preset.attr("printer") = py::cast(PresetEditor{Preset::TYPE_PRINTER});
|
|
||||||
preset.attr("filament") = py::cast(PresetEditor{Preset::TYPE_FILAMENT});
|
|
||||||
|
|
||||||
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
|
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
|
||||||
.def_readonly("type", &Preset::type)
|
.def_readonly("type", &Preset::type)
|
||||||
.def_readonly("name", &Preset::name)
|
.def_readonly("name", &Preset::name)
|
||||||
|
|||||||
@@ -505,6 +505,28 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
|
|||||||
REQUIRE_FALSE(loaded);
|
REQUIRE_FALSE(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A project in the 2.4.x layout keeps auto drop off from its build item", "[3mf][Regression]")
|
||||||
|
{
|
||||||
|
// 2.4.x wrote auto_drop on the build item and left it off the object, so the object is re-laid out here.
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
{
|
||||||
|
Model model;
|
||||||
|
ModelObject* object = model.add_object();
|
||||||
|
object->add_volume(make_cube(10., 10., 10.));
|
||||||
|
object->add_instance();
|
||||||
|
object->auto_drop = false;
|
||||||
|
store_project(model, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
|
||||||
|
}
|
||||||
|
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", " auto_drop=\"0\"", ""));
|
||||||
|
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "printable=\"1\"/>", "printable=\"1\" auto_drop=\"0\"/>"));
|
||||||
|
|
||||||
|
ScopedTemporaryDir backup_dir("orca_auto_drop_dst");
|
||||||
|
Model model;
|
||||||
|
REQUIRE(load_project(temp.string(), model, backup_dir));
|
||||||
|
REQUIRE(model.objects.size() == 1);
|
||||||
|
CHECK_FALSE(model.objects.front()->auto_drop);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
|
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
|
||||||
{
|
{
|
||||||
// The importer queues at most 100000 component references per object. Every reference besides the
|
// The importer queues at most 100000 component references per object. Every reference besides the
|
||||||
|
|||||||
@@ -1219,6 +1219,24 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
|
|||||||
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
|
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The selected preset is its own base when its parent is not loaded", "[Preset]")
|
||||||
|
{
|
||||||
|
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
||||||
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||||
|
add_inmemory_preset(printers, "Printer");
|
||||||
|
add_inmemory_preset(printers, "User Printer", "Printer");
|
||||||
|
add_inmemory_preset(printers, "Orphan Printer", "Uninstalled Vendor Printer");
|
||||||
|
|
||||||
|
printers.select_preset_by_name("User Printer", true);
|
||||||
|
REQUIRE(printers.get_selected_preset_name() == "User Printer");
|
||||||
|
CHECK(printers.get_selected_preset_base().name == "Printer");
|
||||||
|
|
||||||
|
printers.select_preset_by_name("Orphan Printer", true);
|
||||||
|
REQUIRE(printers.get_selected_preset_name() == "Orphan Printer");
|
||||||
|
REQUIRE(printers.get_preset_base(printers.get_selected_preset()) == nullptr);
|
||||||
|
CHECK(printers.get_selected_preset_base().name == "Orphan Printer");
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
||||||
{
|
{
|
||||||
ScopedTemporaryDir temp_dir;
|
ScopedTemporaryDir temp_dir;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
|
|||||||
test_creality_cfs_match.cpp
|
test_creality_cfs_match.cpp
|
||||||
test_dev_mapping.cpp
|
test_dev_mapping.cpp
|
||||||
test_device_access.cpp
|
test_device_access.cpp
|
||||||
test_moonraker_discovery.cpp
|
test_moonraker_printer_agent.cpp
|
||||||
test_network_discovery.cpp
|
test_network_discovery.cpp
|
||||||
test_filament_bitmap_utils.cpp
|
test_filament_bitmap_utils.cpp
|
||||||
test_colour_picker_field.cpp
|
test_colour_picker_field.cpp
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
|
// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
|
||||||
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
|
// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
|
||||||
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
|
// as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
|
||||||
|
// ahead of it).
|
||||||
#ifdef WIN32
|
#ifdef WIN32
|
||||||
#ifndef WIN32_LEAN_AND_MEAN
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
#define WIN32_LEAN_AND_MEAN
|
#define WIN32_LEAN_AND_MEAN
|
||||||
@@ -17,12 +18,15 @@
|
|||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||||
|
|
||||||
#include <imgui/imgui.h>
|
#include <imgui/imgui.h>
|
||||||
|
#include <imgui/imgui_internal.h>
|
||||||
#include <wx/event.h>
|
#include <wx/event.h>
|
||||||
|
|
||||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||||
|
#include "slic3r/GUI/KeyChord.hpp"
|
||||||
|
|
||||||
using Catch::Matchers::WithinAbs;
|
using Catch::Matchers::WithinAbs;
|
||||||
using Slic3r::GUI::ImGuiWrapper;
|
using Slic3r::GUI::ImGuiWrapper;
|
||||||
|
using Slic3r::GUI::KeyChord;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -41,6 +45,45 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
|
|||||||
return ImGui::GetIO().MouseWheel;
|
return ImGui::GetIO().MouseWheel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
|
||||||
|
struct ImGuiFrames
|
||||||
|
{
|
||||||
|
ImGuiWrapper imgui;
|
||||||
|
|
||||||
|
ImGuiFrames()
|
||||||
|
{
|
||||||
|
imgui.set_display_size(800.f, 600.f);
|
||||||
|
unsigned char* pixels = nullptr;
|
||||||
|
int width = 0, height = 0;
|
||||||
|
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class Draw> void frame(Draw draw)
|
||||||
|
{
|
||||||
|
ImGui::NewFrame();
|
||||||
|
draw();
|
||||||
|
ImGui::EndFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
|
||||||
|
void draw_input(bool focus)
|
||||||
|
{
|
||||||
|
ImGui::Begin("window");
|
||||||
|
if (focus)
|
||||||
|
ImGui::SetKeyboardFocusHere();
|
||||||
|
ImGui::InputText("##text", text, sizeof(text));
|
||||||
|
ImGui::End();
|
||||||
|
}
|
||||||
|
|
||||||
|
char text[16] = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
void draw_modal()
|
||||||
|
{
|
||||||
|
if (ImGui::BeginPopupModal("modal"))
|
||||||
|
ImGui::EndPopup();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
||||||
@@ -59,3 +102,62 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
|
|||||||
}));
|
}));
|
||||||
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
|
||||||
|
{
|
||||||
|
ImGuiFrames f;
|
||||||
|
f.frame([&] { f.draw_input(false); });
|
||||||
|
CHECK_FALSE(f.imgui.text_input_active());
|
||||||
|
|
||||||
|
f.frame([&] { f.draw_input(true); });
|
||||||
|
f.frame([&] { f.draw_input(false); });
|
||||||
|
CHECK(f.imgui.text_input_active());
|
||||||
|
|
||||||
|
f.frame([] {});
|
||||||
|
f.frame([] {});
|
||||||
|
CHECK_FALSE(f.imgui.text_input_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
|
||||||
|
{
|
||||||
|
ImGuiFrames f;
|
||||||
|
CHECK_FALSE(f.imgui.modal_popup_open());
|
||||||
|
|
||||||
|
f.frame([] {
|
||||||
|
ImGui::OpenPopup("modal");
|
||||||
|
draw_modal();
|
||||||
|
});
|
||||||
|
CHECK(f.imgui.modal_popup_open());
|
||||||
|
|
||||||
|
// A frame opened by input, before anything is drawn in it.
|
||||||
|
ImGui::NewFrame();
|
||||||
|
CHECK(f.imgui.modal_popup_open());
|
||||||
|
draw_modal();
|
||||||
|
ImGui::EndFrame();
|
||||||
|
CHECK(f.imgui.modal_popup_open());
|
||||||
|
|
||||||
|
// A frame that no longer draws it, as when its canvas is hidden.
|
||||||
|
f.frame([] {});
|
||||||
|
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
|
||||||
|
CHECK_FALSE(f.imgui.modal_popup_open());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
|
||||||
|
{
|
||||||
|
ImGuiFrames f;
|
||||||
|
const KeyChord letter{ 'A' };
|
||||||
|
const KeyChord save{ 'S', wxMOD_CONTROL };
|
||||||
|
CHECK_FALSE(f.imgui.owns_key(letter));
|
||||||
|
CHECK_FALSE(f.imgui.owns_key(save));
|
||||||
|
|
||||||
|
f.frame([&] { f.draw_input(true); });
|
||||||
|
f.frame([&] { f.draw_input(false); });
|
||||||
|
CHECK(f.imgui.owns_key(letter));
|
||||||
|
CHECK_FALSE(f.imgui.owns_key(save));
|
||||||
|
|
||||||
|
f.frame([] {
|
||||||
|
ImGui::OpenPopup("modal");
|
||||||
|
draw_modal();
|
||||||
|
});
|
||||||
|
CHECK(f.imgui.owns_key(save));
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#include <catch2/catch_test_macros.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
|
||||||
|
|
||||||
using namespace Slic3r;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
class DiscoveryAgent : public MoonrakerPrinterAgent
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
|
||||||
using MoonrakerPrinterAgent::device_info;
|
|
||||||
mutable unsigned fetch_count = 0;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
|
||||||
{
|
|
||||||
++fetch_count;
|
|
||||||
info.dev_name = "Test printer";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
|
|
||||||
{
|
|
||||||
DiscoveryAgent agent("");
|
|
||||||
unsigned announcements = 0;
|
|
||||||
nlohmann::json announcement;
|
|
||||||
agent.set_on_ssdp_msg_fn([&](std::string payload) {
|
|
||||||
++announcements;
|
|
||||||
announcement = nlohmann::json::parse(payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
agent.start_discovery(true, false);
|
|
||||||
REQUIRE(announcements == 0);
|
|
||||||
REQUIRE(agent.fetch_count == 0);
|
|
||||||
|
|
||||||
agent.device_info.dev_id = "127.0.0.1:7125";
|
|
||||||
agent.device_info.dev_ip = agent.device_info.dev_id;
|
|
||||||
agent.device_info.base_url = "http://127.0.0.1:7125";
|
|
||||||
agent.start_discovery(true, false);
|
|
||||||
REQUIRE(announcements == 1);
|
|
||||||
REQUIRE(agent.fetch_count == 1);
|
|
||||||
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
|
|
||||||
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
|
|
||||||
REQUIRE(announcement.at("dev_name") == "Test printer");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
#include <catch2/catch_message.hpp>
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <catch2/generators/catch_generators.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <future>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <boost/asio/buffer.hpp>
|
||||||
|
#include <boost/asio/io_context.hpp>
|
||||||
|
#include <boost/asio/ip/address.hpp>
|
||||||
|
#include <boost/asio/ip/tcp.hpp>
|
||||||
|
#include <boost/beast/core/error.hpp>
|
||||||
|
#include <boost/beast/core/flat_buffer.hpp>
|
||||||
|
#include <boost/beast/http/message.hpp>
|
||||||
|
#include <boost/beast/http/read.hpp>
|
||||||
|
#include <boost/beast/http/status.hpp>
|
||||||
|
#include <boost/beast/http/string_body.hpp>
|
||||||
|
#include <boost/beast/http/write.hpp>
|
||||||
|
#include <boost/beast/websocket/error.hpp>
|
||||||
|
#include <boost/beast/websocket/rfc6455.hpp>
|
||||||
|
#include <boost/beast/websocket/stream.hpp>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include "slic3r/Utils/IPrinterAgent.hpp"
|
||||||
|
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||||
|
|
||||||
|
using namespace Slic3r;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
namespace beast = boost::beast;
|
||||||
|
namespace http = beast::http;
|
||||||
|
namespace websocket = beast::websocket;
|
||||||
|
namespace net = boost::asio;
|
||||||
|
using tcp = net::ip::tcp;
|
||||||
|
using namespace std::chrono_literals;
|
||||||
|
|
||||||
|
class DiscoveryAgent : public MoonrakerPrinterAgent
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||||
|
using MoonrakerPrinterAgent::device_info;
|
||||||
|
mutable unsigned fetch_count = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||||
|
{
|
||||||
|
++fetch_count;
|
||||||
|
info.dev_name = "Test printer";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class StreamAgent : public MoonrakerPrinterAgent
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||||
|
using MoonrakerPrinterAgent::start_status_stream;
|
||||||
|
using MoonrakerPrinterAgent::stop_status_stream;
|
||||||
|
using MoonrakerPrinterAgent::ws_timings;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool init_device_info(
|
||||||
|
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
|
||||||
|
{
|
||||||
|
device_info = MoonrakerDeviceInfo{};
|
||||||
|
device_info.dev_id = dev_id;
|
||||||
|
device_info.base_url = "http://" + dev_ip + ":" + port;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||||
|
{
|
||||||
|
info.dev_name = "Test printer";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class Behavior {
|
||||||
|
SilentBeforeHandshake, // Accepts the connection and never answers
|
||||||
|
Silent, // Completes the upgrade, then never reads or sends
|
||||||
|
AnswersPings, // Reports a status, then answers pings and sends nothing else
|
||||||
|
AnswersPingsWithoutStatus, // Answers pings but never reports a status
|
||||||
|
Chatters, // Reports a status, then answers pings and sends notifications that carry no status
|
||||||
|
HangsUp, // Reports a status, then closes the websocket
|
||||||
|
HangsUpWithoutAnswer, // Closes the websocket without sending anything
|
||||||
|
IgnoresClose, // Reports Klippy disconnected, then never answers the close
|
||||||
|
StallsObjectList, // Never answers the stream's object list request
|
||||||
|
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
|
||||||
|
};
|
||||||
|
|
||||||
|
class FakePrinter
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
|
||||||
|
{
|
||||||
|
m_accept_thread = std::thread([this] { accept_loop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
~FakePrinter()
|
||||||
|
{
|
||||||
|
release();
|
||||||
|
m_stopping = true;
|
||||||
|
// Wakes the blocking accept.
|
||||||
|
tcp::socket wake{m_accept_ioc};
|
||||||
|
beast::error_code ec;
|
||||||
|
wake.connect(m_acceptor.local_endpoint(), ec);
|
||||||
|
m_accept_thread.join();
|
||||||
|
for (std::thread& connection : m_connections)
|
||||||
|
connection.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string host() const { return "127.0.0.1"; }
|
||||||
|
std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
|
||||||
|
std::string base_url() const { return "http://" + host() + ":" + port(); }
|
||||||
|
|
||||||
|
// A connection is ready once it reaches the state its behavior waits in.
|
||||||
|
bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wait_for_http_requests(int count)
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
|
||||||
|
bool wait_for_http_hang_ups(int count)
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
|
||||||
|
}
|
||||||
|
|
||||||
|
int close_frames()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
return m_close_frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
|
||||||
|
bool wait_for_close_frames(int count)
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hangs up every connection that is waiting.
|
||||||
|
void release()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
m_released = true;
|
||||||
|
m_changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void accept_loop()
|
||||||
|
{
|
||||||
|
for (;;) {
|
||||||
|
// Each connection gets its own io_context, so a connection that runs one never runs another's handlers.
|
||||||
|
auto ioc = std::make_unique<net::io_context>();
|
||||||
|
tcp::socket socket{*ioc};
|
||||||
|
beast::error_code ec;
|
||||||
|
m_acceptor.accept(socket, ec);
|
||||||
|
if (ec || m_stopping)
|
||||||
|
return;
|
||||||
|
m_connections.emplace_back(
|
||||||
|
[this, ioc = std::move(ioc), socket = std::move(socket)]() mutable { serve(*ioc, std::move(socket)); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void serve(net::io_context& ioc, tcp::socket socket)
|
||||||
|
{
|
||||||
|
if (m_behavior == Behavior::SilentBeforeHandshake) {
|
||||||
|
mark_ready();
|
||||||
|
wait_for_release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
beast::flat_buffer buffer;
|
||||||
|
http::request<http::string_body> request;
|
||||||
|
beast::error_code ec;
|
||||||
|
http::read(socket, buffer, request, ec);
|
||||||
|
if (ec)
|
||||||
|
return;
|
||||||
|
// The agent also queries the printer's status and object list over plain HTTP.
|
||||||
|
if (!websocket::is_upgrade(request)) {
|
||||||
|
serve_http(socket, request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websocket::stream<tcp::socket> ws{std::move(socket)};
|
||||||
|
ws.accept(request, ec);
|
||||||
|
// Without its object list the stream never sends the subscribe request.
|
||||||
|
const int requests = m_behavior == Behavior::StallsObjectList ? 1 : 2;
|
||||||
|
for (int i = 0; i < requests && !ec; ++i) {
|
||||||
|
beast::flat_buffer message;
|
||||||
|
ws.read(message, ec);
|
||||||
|
}
|
||||||
|
if (!ec && (m_behavior == Behavior::AnswersPings || m_behavior == Behavior::Chatters || m_behavior == Behavior::HangsUp)) {
|
||||||
|
const std::string status =
|
||||||
|
nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"result", {{"status", {{"print_stats", {{"state", "standby"}}}}}}}}.dump();
|
||||||
|
ws.write(net::buffer(status), ec);
|
||||||
|
}
|
||||||
|
if (ec)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (m_behavior) {
|
||||||
|
case Behavior::AnswersPings:
|
||||||
|
case Behavior::AnswersPingsWithoutStatus:
|
||||||
|
case Behavior::Chatters:
|
||||||
|
mark_ready();
|
||||||
|
answer_pings(ioc, ws, m_behavior == Behavior::Chatters);
|
||||||
|
break;
|
||||||
|
case Behavior::HangsUp:
|
||||||
|
case Behavior::HangsUpWithoutAnswer:
|
||||||
|
ws.close(websocket::close_code::normal, ec);
|
||||||
|
mark_ready();
|
||||||
|
break;
|
||||||
|
case Behavior::IgnoresClose: {
|
||||||
|
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"}, {"method", "notify_klippy_disconnected"}}.dump();
|
||||||
|
ws.write(net::buffer(notification), ec);
|
||||||
|
mark_ready();
|
||||||
|
wait_for_release();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Behavior::StallsObjectList: answer_pings(ioc, ws, false); break;
|
||||||
|
default: mark_ready(); wait_for_release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void serve_http(tcp::socket& socket, const http::request<http::string_body>& request)
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
++m_http_requests;
|
||||||
|
m_changed.notify_all();
|
||||||
|
}
|
||||||
|
if (m_behavior == Behavior::StallsObjectList) {
|
||||||
|
mark_ready();
|
||||||
|
wait_for_release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_behavior == Behavior::StallsStatusQuery) {
|
||||||
|
wait_for_release();
|
||||||
|
beast::error_code ec;
|
||||||
|
socket.close(ec);
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
++m_http_hung_up;
|
||||||
|
m_changed.notify_all();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
http::response<http::string_body> response{http::status::not_found, request.version()};
|
||||||
|
response.prepare_payload();
|
||||||
|
beast::error_code ec;
|
||||||
|
http::write(socket, response, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps a read pending, which answers pings, until released or closed, and with `chatter` also sends a notification every 50 ms.
|
||||||
|
void answer_pings(net::io_context& ioc, websocket::stream<tcp::socket>& ws, bool chatter)
|
||||||
|
{
|
||||||
|
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"},
|
||||||
|
{"method", "notify_proc_stat_update"},
|
||||||
|
{"params", nlohmann::json::array({nlohmann::json::object()})}}
|
||||||
|
.dump();
|
||||||
|
beast::flat_buffer message;
|
||||||
|
bool reading = true;
|
||||||
|
bool writing = false;
|
||||||
|
std::function<void()> read_next = [&] {
|
||||||
|
ws.async_read(message, [&](beast::error_code ec, std::size_t) {
|
||||||
|
message.clear();
|
||||||
|
if (ec == websocket::error::closed) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
++m_close_frames;
|
||||||
|
m_changed.notify_all();
|
||||||
|
}
|
||||||
|
if (ec)
|
||||||
|
reading = false;
|
||||||
|
else
|
||||||
|
read_next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
read_next();
|
||||||
|
|
||||||
|
auto next_notification = std::chrono::steady_clock::now();
|
||||||
|
while (reading && !wait_for_release(10ms)) {
|
||||||
|
if (chatter && !writing && std::chrono::steady_clock::now() >= next_notification) {
|
||||||
|
writing = true;
|
||||||
|
next_notification += 50ms;
|
||||||
|
ws.async_write(net::buffer(notification), [&](beast::error_code, std::size_t) { writing = false; });
|
||||||
|
}
|
||||||
|
ioc.restart();
|
||||||
|
ioc.run_for(20ms);
|
||||||
|
}
|
||||||
|
beast::error_code ignored;
|
||||||
|
ws.next_layer().close(ignored);
|
||||||
|
while (reading || writing) {
|
||||||
|
ioc.restart();
|
||||||
|
ioc.run_for(20ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void mark_ready()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
++m_ready;
|
||||||
|
m_changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
void wait_for_release()
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
m_changed.wait(lock, [this] { return m_released; });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wait_for_release(std::chrono::milliseconds timeout)
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, timeout, [this] { return m_released; });
|
||||||
|
}
|
||||||
|
|
||||||
|
const Behavior m_behavior;
|
||||||
|
net::io_context m_accept_ioc;
|
||||||
|
tcp::acceptor m_acceptor{m_accept_ioc, {net::ip::make_address("127.0.0.1"), 0}};
|
||||||
|
std::atomic<bool> m_stopping{false};
|
||||||
|
std::thread m_accept_thread;
|
||||||
|
std::vector<std::thread> m_connections;
|
||||||
|
std::mutex m_mutex;
|
||||||
|
std::condition_variable m_changed;
|
||||||
|
int m_ready = 0;
|
||||||
|
int m_http_requests = 0;
|
||||||
|
int m_http_hung_up = 0;
|
||||||
|
int m_close_frames = 0;
|
||||||
|
bool m_released = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Records when the agent dispatches a status report.
|
||||||
|
class Reports
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void attach(StreamAgent& agent)
|
||||||
|
{
|
||||||
|
agent.set_on_local_message_fn([this](std::string, std::string) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
m_times.push_back(std::chrono::steady_clock::now());
|
||||||
|
m_changed.notify_all();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wait_for(std::size_t count)
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_mutex);
|
||||||
|
return m_changed.wait_for(lock, 10s, [&] { return m_times.size() >= count; });
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::chrono::steady_clock::time_point> times()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
return m_times;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::mutex m_mutex;
|
||||||
|
std::condition_variable m_changed;
|
||||||
|
std::vector<std::chrono::steady_clock::time_point> m_times;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A printer and a stream agent connected to it.
|
||||||
|
struct StreamFixture
|
||||||
|
{
|
||||||
|
explicit StreamFixture(Behavior behavior) : printer(behavior) {}
|
||||||
|
|
||||||
|
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
|
||||||
|
~StreamFixture() { printer.release(); }
|
||||||
|
|
||||||
|
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
|
||||||
|
|
||||||
|
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
|
||||||
|
bool stops_within(std::chrono::milliseconds limit)
|
||||||
|
{
|
||||||
|
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
|
||||||
|
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
|
||||||
|
printer.release();
|
||||||
|
stopped.wait();
|
||||||
|
return in_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
FakePrinter printer;
|
||||||
|
StreamAgent agent{""};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerPrinterAgent][Discovery]")
|
||||||
|
{
|
||||||
|
DiscoveryAgent agent("");
|
||||||
|
unsigned announcements = 0;
|
||||||
|
nlohmann::json announcement;
|
||||||
|
agent.set_on_ssdp_msg_fn([&](std::string payload) {
|
||||||
|
++announcements;
|
||||||
|
announcement = nlohmann::json::parse(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.start_discovery(true, false);
|
||||||
|
REQUIRE(announcements == 0);
|
||||||
|
REQUIRE(agent.fetch_count == 0);
|
||||||
|
|
||||||
|
agent.device_info.dev_id = "127.0.0.1:7125";
|
||||||
|
agent.device_info.dev_ip = agent.device_info.dev_id;
|
||||||
|
agent.device_info.base_url = "http://127.0.0.1:7125";
|
||||||
|
agent.start_discovery(true, false);
|
||||||
|
REQUIRE(announcements == 1);
|
||||||
|
REQUIRE(agent.fetch_count == 1);
|
||||||
|
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
|
||||||
|
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
|
||||||
|
REQUIRE(announcement.at("dev_name") == "Test printer");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
// An open websocket gets the 200 ms close timeout, and a stalled object list request up to 1 s of curl progress to see the stop.
|
||||||
|
const auto [behavior, limit] = GENERATE(table<Behavior, std::chrono::milliseconds>({
|
||||||
|
{Behavior::SilentBeforeHandshake, 500ms},
|
||||||
|
{Behavior::Silent, 1000ms},
|
||||||
|
{Behavior::StallsObjectList, 2000ms},
|
||||||
|
}));
|
||||||
|
CAPTURE(static_cast<int>(behavior));
|
||||||
|
|
||||||
|
StreamFixture f(behavior);
|
||||||
|
f.agent.ws_timings.close = 200ms;
|
||||||
|
f.start();
|
||||||
|
REQUIRE(f.printer.wait_for_ready_connections(1));
|
||||||
|
|
||||||
|
CHECK(f.stops_within(limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
// A stop while the stream reads, and one while it waits on the object list request.
|
||||||
|
const Behavior behavior = GENERATE(Behavior::AnswersPings, Behavior::StallsObjectList);
|
||||||
|
CAPTURE(static_cast<int>(behavior));
|
||||||
|
|
||||||
|
StreamFixture f(behavior);
|
||||||
|
f.start();
|
||||||
|
REQUIRE(f.printer.wait_for_ready_connections(1));
|
||||||
|
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
CHECK(f.printer.wait_for_close_frames(1));
|
||||||
|
CHECK(f.printer.close_frames() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Stopping the status stream does not wait out the reconnect delay", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
StreamFixture f(Behavior::HangsUp);
|
||||||
|
f.agent.ws_timings.reconnect_delay = 10s;
|
||||||
|
f.start();
|
||||||
|
REQUIRE(f.printer.wait_for_ready_connections(1));
|
||||||
|
// Lets the stream reach its reconnect wait.
|
||||||
|
std::this_thread::sleep_for(200ms);
|
||||||
|
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The status stream reconnects when the printer stops answering", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
// An unanswered close ends at whichever of the handshake and close timeouts is shorter.
|
||||||
|
const auto [behavior, handshake, close] = GENERATE(table<Behavior, std::chrono::milliseconds, std::chrono::milliseconds>({
|
||||||
|
{Behavior::Silent, 300ms, 1000ms},
|
||||||
|
{Behavior::IgnoresClose, 300ms, 1000ms},
|
||||||
|
{Behavior::IgnoresClose, 1000ms, 200ms},
|
||||||
|
}));
|
||||||
|
CAPTURE(static_cast<int>(behavior), handshake.count(), close.count());
|
||||||
|
|
||||||
|
StreamFixture f(behavior);
|
||||||
|
f.agent.ws_timings.handshake = handshake;
|
||||||
|
f.agent.ws_timings.close = close;
|
||||||
|
f.agent.ws_timings.idle = 400ms;
|
||||||
|
f.agent.ws_timings.reconnect_delay = 20ms;
|
||||||
|
f.start();
|
||||||
|
|
||||||
|
CHECK(f.printer.wait_for_ready_connections(2));
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The status stream reconnects at the shortest delay while the printer answers", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
StreamFixture f(Behavior::HangsUp);
|
||||||
|
f.agent.ws_timings.reconnect_delay = 50ms;
|
||||||
|
f.start();
|
||||||
|
|
||||||
|
// Twelve 50 ms waits; a backoff that kept doubling would need over 10 s.
|
||||||
|
CHECK(f.printer.wait_for_ready_connections(12, 3s));
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The status stream keeps retrying a printer that never answers", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
StreamFixture f(Behavior::HangsUpWithoutAnswer);
|
||||||
|
f.agent.ws_timings.reconnect_delay = 1ms;
|
||||||
|
f.start();
|
||||||
|
|
||||||
|
// The backoff stops doubling at 32 ms, so 20 attempts take well under the wait.
|
||||||
|
CHECK(f.printer.wait_for_ready_connections(20));
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A callback on the stream thread can stop the status stream", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
std::promise<void> stopped;
|
||||||
|
std::once_flag stopped_once;
|
||||||
|
StreamFixture f(Behavior::AnswersPings);
|
||||||
|
f.agent.set_on_local_message_fn([&](std::string, std::string) {
|
||||||
|
std::call_once(stopped_once, [&] {
|
||||||
|
f.agent.stop_status_stream();
|
||||||
|
stopped.set_value();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
f.start();
|
||||||
|
|
||||||
|
REQUIRE(stopped.get_future().wait_for(5s) == std::future_status::ready);
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A quiet printer that is still up keeps reporting its status", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
Reports reports;
|
||||||
|
StreamFixture f(Behavior::AnswersPings);
|
||||||
|
f.agent.ws_timings.idle = 400ms;
|
||||||
|
f.agent.ws_timings.heartbeat = 300ms;
|
||||||
|
reports.attach(f.agent);
|
||||||
|
f.start();
|
||||||
|
|
||||||
|
// The status the printer sent, then two heartbeats.
|
||||||
|
REQUIRE(reports.wait_for(3));
|
||||||
|
const auto times = reports.times();
|
||||||
|
// The steady clock is compared in whole milliseconds, so allow 1 ms of rounding.
|
||||||
|
CHECK(times[1] - times[0] >= 299ms);
|
||||||
|
CHECK(times[2] - times[1] >= 299ms);
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The status stream sends no heartbeat while the printer has nothing new to report", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
const Behavior behavior = GENERATE(Behavior::Chatters, Behavior::AnswersPingsWithoutStatus);
|
||||||
|
CAPTURE(static_cast<int>(behavior));
|
||||||
|
|
||||||
|
Reports reports;
|
||||||
|
StreamFixture f(behavior);
|
||||||
|
f.agent.ws_timings.idle = 400ms;
|
||||||
|
f.agent.ws_timings.heartbeat = 800ms;
|
||||||
|
reports.attach(f.agent);
|
||||||
|
f.start();
|
||||||
|
REQUIRE(f.printer.wait_for_ready_connections(1));
|
||||||
|
|
||||||
|
// The chattering printer's status, and nothing from the one that never sent a status.
|
||||||
|
const std::size_t expected = behavior == Behavior::Chatters ? 1 : 0;
|
||||||
|
std::this_thread::sleep_for(1500ms);
|
||||||
|
CHECK(reports.times().size() == expected);
|
||||||
|
CHECK(f.stops_within(3s));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A disconnect during the connection queries keeps the status stream from starting", "[MoonrakerPrinterAgent][StatusStream]")
|
||||||
|
{
|
||||||
|
StreamFixture f(Behavior::StallsStatusQuery);
|
||||||
|
PrinterConnectionParams params;
|
||||||
|
params.dev_id = "printer";
|
||||||
|
params.host = f.printer.host();
|
||||||
|
params.port = f.printer.port();
|
||||||
|
f.agent.connect_printer(params);
|
||||||
|
REQUIRE(f.printer.wait_for_http_requests(1));
|
||||||
|
|
||||||
|
f.agent.disconnect_printer();
|
||||||
|
f.printer.release();
|
||||||
|
// The query has returned once the printer hangs up on it, and a stream would connect right after.
|
||||||
|
REQUIRE(f.printer.wait_for_http_hang_ups(1));
|
||||||
|
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
using namespace Slic3r;
|
using namespace Slic3r;
|
||||||
namespace fs = boost::filesystem;
|
namespace fs = boost::filesystem;
|
||||||
@@ -325,6 +326,117 @@ TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Plugin audit network permissions match by scheme and host", "[audit]")
|
||||||
|
{
|
||||||
|
SECTION("a URL is reduced to its scheme and host")
|
||||||
|
{
|
||||||
|
CHECK(PluginAuditManager::normalize_network_url("https://API.Example.com:8443/v1/items?page=2#top") ==
|
||||||
|
"https://api.example.com");
|
||||||
|
CHECK(PluginAuditManager::normalize_network_url("http://example.com") == "http://example.com");
|
||||||
|
CHECK(PluginAuditManager::normalize_network_url("https://[2001:db8::1]:443/path") == "https://[2001:db8::1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("non-http schemes and targets without a scheme are not network URLs")
|
||||||
|
{
|
||||||
|
CHECK(PluginAuditManager::normalize_network_url("ftp://example.com").empty());
|
||||||
|
CHECK(PluginAuditManager::normalize_network_url("api.example.com").empty());
|
||||||
|
CHECK(PluginAuditManager::normalize_network_url("https://").empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a URL target matches the same scheme and host regardless of path and port")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> granted{"https://api.example.com"};
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com/v1/items?page=2"));
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com:8443/upload"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a URL target does not match a different host or a different scheme")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> granted{"https://api.example.com"};
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://evil.example.com/v1"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "http://api.example.com/v1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a bare host target matches the host of a grant")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> granted{"https://api.example.com"};
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com"));
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com:443"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "other.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("legacy persisted address tuples keep matching their host")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> legacy_granted{"('api.example.com', 443)"};
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(legacy_granted, "api.example.com"));
|
||||||
|
|
||||||
|
const std::vector<std::string> granted{"https://api.example.com"};
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "('api.example.com', 443)"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "('93.184.216.34', 443)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("an IPv6 literal matches across bracketed and bare forms")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> granted{"https://[2001:db8::1]"};
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "2001:db8::1"));
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "[2001:db8::1]:443"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "2001:db8::2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a non-http URL only matches an exact grant")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> granted{"ftp://example.com/file"};
|
||||||
|
CHECK(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/file"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/other"));
|
||||||
|
|
||||||
|
const std::vector<std::string> https{"https://api.example.com"};
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(https, "ftp://api.example.com/file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("an empty grant list or an empty target never matches")
|
||||||
|
{
|
||||||
|
const std::vector<std::string> granted{};
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://api.example.com"));
|
||||||
|
|
||||||
|
const std::vector<std::string> any{"https://api.example.com"};
|
||||||
|
CHECK_FALSE(PluginAuditManager::network_permission_matches(any, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Plugin audit limits network ancestor approvals to resolved addresses", "[audit]")
|
||||||
|
{
|
||||||
|
SECTION("IP literals are distinguished from hostnames and URLs")
|
||||||
|
{
|
||||||
|
CHECK(PluginAuditManager::is_ip_address_target("127.0.0.1"));
|
||||||
|
CHECK(PluginAuditManager::is_ip_address_target("93.184.216.34"));
|
||||||
|
CHECK(PluginAuditManager::is_ip_address_target("::1"));
|
||||||
|
CHECK(PluginAuditManager::is_ip_address_target("2001:db8::1"));
|
||||||
|
CHECK(PluginAuditManager::is_ip_address_target("[2001:db8::1]"));
|
||||||
|
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_ip_address_target("api.example.com"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_ip_address_target("localhost"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_ip_address_target("https://api.example.com"));
|
||||||
|
CHECK_FALSE(PluginAuditManager::is_ip_address_target(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a network approval is kept apart from the general call-site cascade")
|
||||||
|
{
|
||||||
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||||
|
const std::string plugin_key = "plugin-audit-network-ancestor";
|
||||||
|
const std::string other_key = "plugin-audit-network-ancestor-other";
|
||||||
|
|
||||||
|
mgr.record_approved_network_call_sites(plugin_key, {"frame:network"});
|
||||||
|
mgr.record_approved_call_sites(plugin_key, {"frame:general"});
|
||||||
|
|
||||||
|
CHECK(mgr.has_approved_network_ancestor(plugin_key, {"frame:network"}));
|
||||||
|
CHECK_FALSE(mgr.has_approved_network_ancestor(plugin_key, {"frame:general"}));
|
||||||
|
CHECK_FALSE(mgr.has_approved_network_ancestor(other_key, {"frame:network"}));
|
||||||
|
|
||||||
|
// The network approval must not leak into the general cascade used by other categories.
|
||||||
|
CHECK_FALSE(mgr.has_approved_ancestor(plugin_key, {"frame:network"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
|
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
|
||||||
{
|
{
|
||||||
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
|
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
|
||||||
|
|||||||
@@ -1,20 +1,3 @@
|
|||||||
// why: keep the Windows headers ahead of libslic3r. Model.hpp pulls in Format/STEP.hpp, and
|
|
||||||
// OCCT's Standard_Macro.hxx defines NOUSER and NOGDI on Windows so that windows.h does not
|
|
||||||
// collide with OCCT's identifiers. Defined that early they also hide the USER and GDI
|
|
||||||
// declarations, and the wx MSW headers Field.hpp reaches (wx/wxprec.h -> wx/msw/private.h)
|
|
||||||
// need WNDPROC, RGB and the ::GetWindowRect family. Including windows.h first, as
|
|
||||||
// libslic3r_gui does through pchheader.hpp or win_platform.hpp, keeps them.
|
|
||||||
#ifdef WIN32
|
|
||||||
#ifndef WIN32_LEAN_AND_MEAN
|
|
||||||
#define WIN32_LEAN_AND_MEAN
|
|
||||||
#endif
|
|
||||||
#ifndef NOMINMAX
|
|
||||||
#define NOMINMAX
|
|
||||||
#endif
|
|
||||||
#include <Windows.h>
|
|
||||||
#include <CommCtrl.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include <catch2/catch_all.hpp>
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -23,12 +6,8 @@
|
|||||||
#include <libslic3r/TriangleMesh.hpp>
|
#include <libslic3r/TriangleMesh.hpp>
|
||||||
#include <slic3r/GUI/DockPanel.hpp>
|
#include <slic3r/GUI/DockPanel.hpp>
|
||||||
#include <slic3r/GUI/AuiPaneLayout.hpp>
|
#include <slic3r/GUI/AuiPaneLayout.hpp>
|
||||||
#include <slic3r/GUI/OptionsGroup.hpp>
|
|
||||||
#include <slic3r/GUI/Widgets/WebHosting.hpp>
|
#include <slic3r/GUI/Widgets/WebHosting.hpp>
|
||||||
|
|
||||||
#include <map>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
#include <catch2/catch_message.hpp>
|
#include <catch2/catch_message.hpp>
|
||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||||
@@ -158,143 +137,6 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
|
|||||||
CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast<std::string>() == printer_preset.name);
|
CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast<std::string>() == printer_preset.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Plugin host API exposes the editable preset category handles and guards them before Orca app initialization",
|
|
||||||
"[PluginHost][Python]")
|
|
||||||
{
|
|
||||||
py::object host = import_orca_module().attr("host");
|
|
||||||
py::object preset = host.attr("preset");
|
|
||||||
|
|
||||||
REQUIRE(has_attr(preset, "PresetEditor"));
|
|
||||||
REQUIRE(has_attr(preset, "process"));
|
|
||||||
REQUIRE(has_attr(preset, "printer"));
|
|
||||||
REQUIRE(has_attr(preset, "filament"));
|
|
||||||
|
|
||||||
py::object process = preset.attr("process");
|
|
||||||
py::object printer = preset.attr("printer");
|
|
||||||
py::object filament = preset.attr("filament");
|
|
||||||
|
|
||||||
CHECK(process.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_PRINT);
|
|
||||||
CHECK(printer.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_PRINTER);
|
|
||||||
CHECK(filament.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_FILAMENT);
|
|
||||||
for (const py::object& editor : { process, printer, filament })
|
|
||||||
CHECK(has_attr(editor, "update"));
|
|
||||||
|
|
||||||
// With no wx application the call must fail cleanly, like the rest of host.preset, and must not
|
|
||||||
// be mistaken for a missing attribute or an argument error.
|
|
||||||
for (const py::object& editor : { process, printer, filament }) {
|
|
||||||
try {
|
|
||||||
editor.attr("update")("layer_height", "0.25");
|
|
||||||
FAIL("preset editor update 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Plugin host API exposes the filament slot form of preset update and guards it", "[PluginHost][Python]")
|
|
||||||
{
|
|
||||||
py::object host = import_orca_module().attr("host");
|
|
||||||
py::object preset = host.attr("preset");
|
|
||||||
|
|
||||||
// The filament editor accepts update(slot_number, key, value) as well as update(key, value); with
|
|
||||||
// no wx application it reaches the same clean failure as the rest of host.preset. The slot
|
|
||||||
// validation and the switch/cancel path need a running app, so they are not exercised here.
|
|
||||||
try {
|
|
||||||
preset.attr("filament").attr("update")(1, "layer_height", "0.25");
|
|
||||||
FAIL("slot-addressed filament update 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The process and printer editors accept only update(key, value); the slot form is rejected
|
|
||||||
// before any application access, naming the editor that does accept it.
|
|
||||||
for (const char* name : { "process", "printer" }) {
|
|
||||||
CAPTURE(name);
|
|
||||||
try {
|
|
||||||
preset.attr(name).attr("update")(1, "layer_height", "0.25");
|
|
||||||
FAIL("slot-addressed update on a non-filament editor unexpectedly succeeded");
|
|
||||||
} catch (const py::error_already_set& error) {
|
|
||||||
CHECK(error.matches(PyExc_ValueError));
|
|
||||||
CHECK(std::string(error.what()).find("only available on") != std::string::npos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Plugin host API exposes staged preset update transactions", "[PluginHost][Python]")
|
|
||||||
{
|
|
||||||
py::object preset = import_orca_module().attr("host").attr("preset");
|
|
||||||
py::object transaction_type = preset.attr("PresetTransaction");
|
|
||||||
py::object result_type = preset.attr("PresetUpdateResult");
|
|
||||||
|
|
||||||
CHECK(has_attr(transaction_type, "set"));
|
|
||||||
CHECK(has_attr(transaction_type, "commit"));
|
|
||||||
CHECK(has_attr(result_type, "target_name"));
|
|
||||||
CHECK(has_attr(result_type, "selection_changed"));
|
|
||||||
CHECK(has_attr(result_type, "slot_assignment_changed"));
|
|
||||||
CHECK(has_attr(result_type, "shared_slots"));
|
|
||||||
|
|
||||||
for (const char* name : { "process", "printer" }) {
|
|
||||||
CAPTURE(name);
|
|
||||||
py::object editor = preset.attr(name);
|
|
||||||
CHECK(has_attr(editor, "begin_update"));
|
|
||||||
try {
|
|
||||||
editor.attr("begin_update")();
|
|
||||||
FAIL("begin_update 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
py::object filament = preset.attr("filament");
|
|
||||||
CHECK(has_attr(filament, "begin_update"));
|
|
||||||
try {
|
|
||||||
filament.attr("begin_update")(1);
|
|
||||||
FAIL("filament begin_update 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("A settings group resolves an option's id from its option map", "[PluginHost]")
|
|
||||||
{
|
|
||||||
using Slic3r::GUI::option_id_in_map;
|
|
||||||
using Slic3r::GUI::t_opt_map;
|
|
||||||
|
|
||||||
// The map ConfigOptionsGroup::get_option leaves: a scalar option, and a per-extruder option
|
|
||||||
// whose entries carry their index.
|
|
||||||
t_opt_map options;
|
|
||||||
options.emplace("layer_height", std::make_pair(std::string("layer_height"), -1));
|
|
||||||
options.emplace("nozzle_diameter#0", std::make_pair(std::string("nozzle_diameter"), 0));
|
|
||||||
options.emplace("nozzle_diameter#1", std::make_pair(std::string("nozzle_diameter"), 1));
|
|
||||||
|
|
||||||
// A negative index resolves a scalar option and yields its own (negative) index.
|
|
||||||
int index = -1;
|
|
||||||
CHECK(option_id_in_map(options, "layer_height", index) == "layer_height");
|
|
||||||
CHECK(index == -1);
|
|
||||||
|
|
||||||
// A negative index on a vector option matches several entries, so it resolves to nothing.
|
|
||||||
index = -1;
|
|
||||||
CHECK(option_id_in_map(options, "nozzle_diameter", index).empty());
|
|
||||||
|
|
||||||
// An explicit index selects that entry.
|
|
||||||
index = 1;
|
|
||||||
CHECK(option_id_in_map(options, "nozzle_diameter", index) == "nozzle_diameter#1");
|
|
||||||
CHECK(index == 1);
|
|
||||||
|
|
||||||
// An index no entry carries, and a key the map does not own, resolve to nothing.
|
|
||||||
index = 7;
|
|
||||||
CHECK(option_id_in_map(options, "nozzle_diameter", index).empty());
|
|
||||||
index = -1;
|
|
||||||
CHECK(option_id_in_map(options, "wall_loops", index).empty());
|
|
||||||
|
|
||||||
const t_opt_map empty;
|
|
||||||
CHECK(option_id_in_map(empty, "layer_height", index).empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
|
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
|
||||||
{
|
{
|
||||||
py::object host = import_orca_module().attr("host");
|
py::object host = import_orca_module().attr("host");
|
||||||
|
|||||||
@@ -615,8 +615,22 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
|
|||||||
|
|
||||||
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
||||||
write_package("'new'");
|
write_package("'new'");
|
||||||
boost::system::error_code ec;
|
{
|
||||||
fs::remove_all(package_root / "__pycache__", ec);
|
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
|
||||||
|
// interpreter caches it.
|
||||||
|
PythonGILState gil;
|
||||||
|
REQUIRE(gil);
|
||||||
|
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
|
||||||
|
REQUIRE(util);
|
||||||
|
for (const char* file : {"__init__.py", "sub.py"}) {
|
||||||
|
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
|
||||||
|
REQUIRE(source);
|
||||||
|
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
|
||||||
|
REQUIRE(cached);
|
||||||
|
boost::system::error_code ec;
|
||||||
|
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
paths.clear();
|
paths.clear();
|
||||||
modules.clear();
|
modules.clear();
|
||||||
|
|||||||
@@ -126,6 +126,55 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
|
|||||||
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
|
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
|
||||||
|
{
|
||||||
|
CHECK(KeyChord{ 'A' }.edits_text());
|
||||||
|
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_SPACE }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_BACK }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_DELETE }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_HOME }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_INSERT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_RETURN }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
|
||||||
|
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
|
||||||
|
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
||||||
|
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{}.edits_text());
|
||||||
|
#ifdef __APPLE__
|
||||||
|
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
|
||||||
|
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
|
||||||
|
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
||||||
|
#else
|
||||||
|
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
|
||||||
|
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
|
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
|
||||||
{
|
{
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -186,6 +235,21 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
|
||||||
|
{
|
||||||
|
ShortcutRegistry registry;
|
||||||
|
size_t checked = 0;
|
||||||
|
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
|
||||||
|
const ShortcutInfo& info = shortcut_info(Shortcut(i));
|
||||||
|
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
|
||||||
|
continue;
|
||||||
|
CAPTURE(info.key);
|
||||||
|
CHECK_FALSE(registry.binding(info.id).edits_text());
|
||||||
|
++checked;
|
||||||
|
}
|
||||||
|
CHECK(checked > 0);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
|
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
|
||||||
{
|
{
|
||||||
ShortcutRegistry registry;
|
ShortcutRegistry registry;
|
||||||
|
|||||||
Reference in new issue
Block a user