From 74691fd036736c9eb97b25312abaf5fadf94594a Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 19 May 2026 13:58:21 +0200 Subject: [PATCH 01/37] Bumped version --- CHANGELOG.md | 2 +- backend/app/core/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6675c5ab..4c8920abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Bambuddy will be documented in this file. -## [0.2.5b1] - Unreleased +## [0.2.4.2] - 2026-05-19 ### Added - **Docker: opt-in system trust store for self-signed CA certificates (#1431, contributed by @WizBangCrash, requested in #1289)** — Reporter runs a private LAN with self-signed certificates for internal HTTPS endpoints (his Home Assistant instance being the canonical case) and wanted Bambuddy to trust those CAs without disabling TLS verification end-to-end. Bambuddy talks to Home Assistant via `httpx.AsyncClient` (`backend/app/services/homeassistant.py:46`) with default `verify=True`, which under httpx 0.28 means "use `certifi`'s CA bundle and nothing else" — so manually copying a CA file into the container had no effect. **The fix is opt-in and container-side only**: setting `USE_SYSTEM_TRUST_STORE=` in the compose `environment:` block, combined with mounting the user's CA file(s) into `/usr/local/share/ca-certificates`, makes the entrypoint run `update-ca-certificates --fresh` at startup and `export SSL_CERT_DIR=/etc/ssl/certs`. httpx 0.28 explicitly honours that env var (`_config.py`: `ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])`), and `update-ca-certificates` populates `/etc/ssl/certs` with the **Debian system CA bundle** (Let's Encrypt, DigiCert, GlobalSign, etc.) **plus** the user-mounted CAs — so standard endpoints (api.github.com, MakerWorld, Bambu Cloud) keep working alongside the user's self-signed CA. The `ca-certificates` apt package is added to the Dockerfile so `update-ca-certificates` exists in the image. The feature is **default-off** — when the env var is unset the entrypoint logs a one-line "skipping system trust store update" and goes straight to the existing PUID/PGID chown path, so non-users see zero behaviour change. **Fail-fast on misconfig**: if `USE_SYSTEM_TRUST_STORE` is set but the container is running as non-root (the entrypoint can't write `/etc/ssl/certs` without root), or `/usr/local/share/ca-certificates` has no `.crt` files mounted, or `update-ca-certificates` is missing from the image, or the trust-store rebuild itself fails, the entrypoint exits 1 with a clear error message rather than silently succeeding and leaving the user wondering why their HA connection still rejects the cert. **Compose template update**: `docker-compose.yml` ships commented-out examples for both the volume mount (`/path/to/certs:/usr/local/share/ca-certificates`) and the env var (`USE_SYSTEM_TRUST_STORE=true`) so the path from "I have a self-signed CA" to "Bambuddy trusts it" is two uncommented lines. **Caveat worth flagging in docs**: the feature requires the container to start as root so the entrypoint can run `update-ca-certificates`; users who pin `user: "1000:1000"` in compose get the clear "not running as root" exit with the reason, but they need to switch to the default PUID/PGID-style invocation to use this. Companion wiki PR documents the setup walkthrough at maziggy/bambuddy-wiki#31. Hardware-only path (shell entrypoint change) so no automated test — verified by the reporter's local install. Post-merge polish: the fatal-exit branch's log line was relabeled from "warning: update-ca-certificates failed:" to "error: update-ca-certificates failed" to match severity and the surrounding error messages. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2fab2f3dc..09b92c6d4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,7 +6,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.2.5b1" +APP_VERSION = "0.2.4.2" GITHUB_REPO = "maziggy/bambuddy" BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report") From 162db57923d3918c6818614eaeb34d95c101e000 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 19 May 2026 14:17:31 +0200 Subject: [PATCH 02/37] chore(security): nosec false-positive Bandit findings in tests PR #1434 CI flagged 5 B402 (ftplib import) in test_bambu_ftp.py and 2 B108 (hardcoded /tmp) in test_print_start_assigns_printer_id_to_vp_archive.py. Both are intentional in tests: the FTP client tests need real ftplib exception classes to construct mock 426 responses, and the /tmp path is a MagicMock attribute never written to. Marked with `# nosec B402` / `# nosec B108` plus a one-line justification each, matching the convention from c2630399. --- backend/tests/unit/services/test_bambu_ftp.py | 10 +++++----- ...est_print_start_assigns_printer_id_to_vp_archive.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/tests/unit/services/test_bambu_ftp.py b/backend/tests/unit/services/test_bambu_ftp.py index 6ccb2c1c7..10e52bd11 100644 --- a/backend/tests/unit/services/test_bambu_ftp.py +++ b/backend/tests/unit/services/test_bambu_ftp.py @@ -397,7 +397,7 @@ class TestUpload: succeeding on the printer side (v0.2.4.1 worked because the prior proceed-with-warning branch tolerated the noise). """ - import ftplib + import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses local = tmp_path / "test.bin" local.write_bytes(b"data" * 256) # 1024 bytes @@ -427,7 +427,7 @@ class TestUpload: isn't on the server at full size (or SIZE itself fails), the upload must fail so the dispatcher doesn't send a print command for a partial 3MF.""" - import ftplib + import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses local = tmp_path / "test.bin" local.write_bytes(b"data" * 256) @@ -452,7 +452,7 @@ class TestUpload: """If SIZE itself fails (e.g. server too broken to answer), assume the worst and fail — better a retry than a print on a partial file. """ - import ftplib + import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses local = tmp_path / "test.bin" local.write_bytes(b"data" * 256) @@ -474,7 +474,7 @@ class TestUpload: def test_upload_bytes_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server): """upload_bytes() mirrors the same SIZE-verify logic as upload_file.""" - import ftplib + import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses client = ftp_client_factory() client.connect() @@ -495,7 +495,7 @@ class TestUpload: def test_upload_bytes_426_with_truncated_file_returns_false(self, ftp_client_factory, ftp_server): """The truncated branch for upload_bytes().""" - import ftplib + import ftplib # nosec B402 — tests need the real ftplib to construct mock 426 responses client = ftp_client_factory() client.connect() diff --git a/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py b/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py index 18e9a549a..54fa0dff8 100644 --- a/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py +++ b/backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py @@ -64,7 +64,7 @@ async def test_expected_archive_path_assigns_printer_id_when_unset(): mock_archive.printer_id = None mock_archive.print_name = "A1 Tool Plate 3" mock_archive.status = "archived" - mock_archive.file_path = "/tmp/fake.3mf" + mock_archive.file_path = "/tmp/fake.3mf" # nosec B108 — mock path; nothing ever writes to it mock_archive.energy_start_kwh = None register_expected_print(1, "bambu_lab_a1_tool_plate_3.gcode.3mf", archive_id=42, ams_mapping=None) @@ -151,7 +151,7 @@ async def test_expected_archive_path_preserves_existing_printer_id(): mock_archive.printer_id = 7 # already correct mock_archive.print_name = "MyModel" mock_archive.status = "archived" - mock_archive.file_path = "/tmp/fake.3mf" + mock_archive.file_path = "/tmp/fake.3mf" # nosec B108 — mock path; nothing ever writes to it mock_archive.energy_start_kwh = None register_expected_print(7, "MyModel.3mf", archive_id=99, ams_mapping=None) From 6da7d1edeb905841fcda80a786c0ecdfd4c1d6ac Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 20 May 2026 11:39:44 +0200 Subject: [PATCH 03/37] Updated BACKERS.md --- BACKERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BACKERS.md b/BACKERS.md index f51f97fec..0a80ef6b0 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -28,6 +28,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@rewart01](https://github.com/rewart01) - [@rstocks](https://github.com/rstocks) - [@sixfootseven](https://github.com/sixfootseven) +- [@pwostran](https://github.com/pwostran) ## Backers ($5/mo+) @@ -36,7 +37,6 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@grizz0blaw](https://github.com/grizz0blaw) - [@NoahTingey](https://github.com/NoahTingey) - [@sentinel-center](https://github.com/sentinel-center) -- [@pwostran](https://github.com/pwostran) --- ## One-time and historical supporters From fdd20e49b3db30425aafc4eaaa765e27ad0de2f5 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 20 May 2026 12:36:32 +0200 Subject: [PATCH 04/37] chore(security): bump idna >=3.15 (CVE-2026-45409) + ignore disputed PyJWT advisory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requirements.txt: pin idna>=3.15 to clear ReDoS in idna.encode() on crafted Unicode payloads. Transitive via anyio/httpx/requests/yarl, so the explicit floor stops a future downstream loosening from silently downgrading us. - security.yml: permanently --ignore-vuln CVE-2025-45768 (PyJWT). The advisory is disputed by the maintainers — "key length is chosen by the application" — and no fix version exists. Bambuddy is safe: auto-generates secrets via secrets.token_urlsafe(64) and rejects file-loaded secrets shorter than 32 chars (auth.py:177, :184). - security.yml: drop the stale Pygments --ignore-vuln CVE-2026-4539. Pygments has been patched upstream; the ignore no longer matches anything. --- .github/workflows/security.yml | 15 +++++++++++---- CHANGELOG.md | 6 ++++++ requirements.txt | 5 +++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fd02de967..7033440ab 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -132,10 +132,17 @@ jobs: - name: Run pip-audit id: pip-audit run: | - # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich). - # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version. - pip-audit --desc on --format json --output pip-audit-results.json --ignore-vuln CVE-2026-4539 || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT - pip-audit --desc on --ignore-vuln CVE-2026-4539 || true + # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers. + # Advisory says "key length is chosen by the application that uses the library" — no + # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses + # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and + # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently. + pip-audit --desc on --format json --output pip-audit-results.json \ + --ignore-vuln CVE-2025-45768 \ + || echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT + pip-audit --desc on \ + --ignore-vuln CVE-2025-45768 \ + || true - name: Upload audit results if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8920abf..a133692fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Bambuddy will be documented in this file. +## [0.2.4.3] - Unreleased + +### Security +- **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. +- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). + ## [0.2.4.2] - 2026-05-19 ### Added diff --git a/requirements.txt b/requirements.txt index 4a2762129..3c855b1a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,6 +60,11 @@ passlib[bcrypt]>=1.7.4 ldap3>=2.9.0 pyotp>=2.9.0 +# Transitive dep pin: idna<3.15 has CVE-2026-45409 (ReDoS on encode() with +# crafted Unicode). Pulled in by anyio/httpx/requests/yarl; pin the floor +# so we don't regress when a downstream loosens its constraint. +idna>=3.15 + # HTTP client (used for OIDC token exchange) httpx>=0.26.0 From 90f68820328747ef6af7ed2efb3c3ff2ffc78473 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 20 May 2026 12:43:10 +0200 Subject: [PATCH 05/37] Updated .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ffaee2ce3..3c83decb2 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ advertisements/ # gitleaks reports gitleaks-report.json + +scripts/pip-audit.sh From 93118b9c3e3c3f77333a7ac33cd41fe18fff8644 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 20 May 2026 13:21:59 +0200 Subject: [PATCH 06/37] chore(ci): also ignore disputed PyJWT CVE-2025-45768 in ci.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security.yml had this ignore added in 9d440beb but ci.yml runs its own pip-audit step with a separate ignore list. CI was still failing on main + dev. Reasoning identical to the security.yml comment — disputed by PyJWT maintainers, no fix exists, Bambuddy uses secrets.token_urlsafe(64) and rejects short secrets. --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 493404ebd..005307934 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,15 @@ jobs: run: | # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich). # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version. - pip-audit --desc on --ignore-vuln CVE-2026-4539 + # + # CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): disputed by PyJWT maintainers. + # Advisory says "key length is chosen by the application that uses the library" — no + # PyJWT fix exists or will exist. Bambuddy is safe: backend/app/core/auth.py:184 uses + # secrets.token_urlsafe(64) (~86 chars of entropy) for auto-generated secrets and + # rejects file-loaded secrets shorter than 32 chars at :177. Keep ignored permanently. + pip-audit --desc on \ + --ignore-vuln CVE-2026-4539 \ + --ignore-vuln CVE-2025-45768 backend-tests: name: Backend Tests From be669a7aeab1db48b4c102fabdaf4af6f0ff8d1a Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 21 May 2026 11:01:53 +0200 Subject: [PATCH 07/37] Updated .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/config.yml --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 ++ .github/ISSUE_TEMPLATE/config.yml | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 58332a262..8a8ef0fc3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -172,3 +172,5 @@ body: required: true - label: My printer has Developer Mode enabled required: true + - label: For a connection or printing problem, I ran the in-app Connection Diagnostic (printer card or System page) and included the result above + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c3cb5d3b8..e873cd805 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,11 @@ blank_issues_enabled: false contact_links: + - name: Printer won't connect or won't print? + url: https://wiki.bambuddy.cool/reference/troubleshooting/ + about: Most connection and printing problems are setup issues. Check the troubleshooting guide and run the in-app Connection Diagnostic (printer card or System page) before opening an issue. - name: Documentation - url: https://github.com/maziggy/bambuddy-wiki - about: Check the documentation for guides and troubleshooting + url: https://wiki.bambuddy.cool/ + about: Setup guides, feature documentation, and reference. - name: Community Forum url: https://forum.bambuddy.cool - about: Ask questions and share ideas with the community + about: Ask questions and share ideas with the community. From 6d673d062624387a699bde1a885d82e605395a97 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 23 May 2026 08:05:20 +0200 Subject: [PATCH 08/37] Updated BACKERS --- BACKERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BACKERS.md b/BACKERS.md index 0a80ef6b0..e93775ce2 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -29,6 +29,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@rstocks](https://github.com/rstocks) - [@sixfootseven](https://github.com/sixfootseven) - [@pwostran](https://github.com/pwostran) +- [@MethodicalMartian](https://github.com/MethodicalMartian) ## Backers ($5/mo+) From cc468ddd42fbb09878fceb2805a288e1235640d7 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 09:45:42 +0200 Subject: [PATCH 09/37] Updated BACKERS.md --- BACKERS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BACKERS.md b/BACKERS.md index e93775ce2..8fe0293c2 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -38,6 +38,8 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@grizz0blaw](https://github.com/grizz0blaw) - [@NoahTingey](https://github.com/NoahTingey) - [@sentinel-center](https://github.com/sentinel-center) +- [@brianehlert](https://github.com/brianehlert) +- [@siiruup](https://github.com/siiruup) --- ## One-time and historical supporters From f513c2c1db623945af9bd6aeea930af55f41ce6a Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 10:59:24 +0200 Subject: [PATCH 10/37] Bumped version --- CHANGELOG.md | 2 +- backend/app/core/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcb283b4..ddac477d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Bambuddy will be documented in this file. -## [0.2.5b1] - Unreleased +## [0.2.4.3] - 2026-05-24 ### Added - **SliceModal: "Slice all plates" toggle for multi-plate sources** — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting `plate=0` instead of the picked plate index. The backend forwards this as the BS CLI's `--slice 0` "all plates" sentinel, which produces a **single output 3MF whose `Metadata/plate_N.gcode` entries cover every plate** — one slice call, one archive, every plate inside. **Filament dropdowns also adapt**: with the toggle on, they show the *union* of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existing `platesQuery.data.plates[*].filaments` payload, so no extra round-trip. The backend `SliceRequest.plate` field's range relaxed from `ge=1` to `ge=0` to admit the sentinel (the schema's docstring spells out the three semantics: `None` → default plate 1, `0` → all plates, `>= 1` → that plate). The substitute-unused-filaments pass becomes a no-op for `plate=0` (no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. **Cross-class slice-all is handled by a per-plate loop**: BS CLI's `--arrange` is project-wide, so `--slice 0 --arrange 1` on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detects `plate=0` combined with a class crossing, it falls back to slicing each plate independently (`plate=N, arrange=true`), then merges the N single-plate 3MF outputs into one multi-plate 3MF in `merge_plate_3mfs` — overlays each plate's `Metadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}` onto the first plate's base 3MF and re-assembles `Metadata/slice_info.config` to list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. New `count_plates_in_3mf` parses `model_settings.config` for `` entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. **Progress toast shows loop position**: each per-plate sub-slice forwards the original `progress_request_id` + callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented with `multi_plate_index` / `multi_plate_count` — the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. New `slice.runningWithProgressMultiPlate` i18n key translated across all 9 locales. **Per-plate cover images preserved**: BS CLI with `--arrange` regenerates plate gcodes but rarely writes a fresh `Metadata/plate_N.png`, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (`plate_N.png` / `plate_N_small.png`) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. **Final test coverage**: 26 unit tests in `test_slicer_3mf_convert.py` (extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 in `test_slicer_api.py` (arrange flag wire format on preset and bundle paths) + 9 in `test_library_slice_api.py` (guard no-op semantics, re-sliced thumbnail / bed_type lifts, **a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate with `arrange=true`, and verifies the merged archive contains `plate_1..plate_N.gcode`**) + 2 in `test_archive_service.py` (Auxiliaries thumbnail fallback) + 4 in `SliceModal.test.tsx` (slice-all toggle sends `plate=0`, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new in `SliceJobTrackerContext.test.tsx` (toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests in `SliceModal.test.tsx` (toggle sends `plate=0` to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2fab2f3dc..ae5e49110 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,7 +6,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.2.5b1" +APP_VERSION = "0.2.4.3" GITHUB_REPO = "maziggy/bambuddy" BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report") From 07ea69c3f0fc405ac6ea7d9a96cb67fe4b444abe Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 11:26:14 +0200 Subject: [PATCH 11/37] test(docker): include gcode_viewer/ in the test image so the packaging assertion actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile.test only COPYed backend/ and pyproject.toml, so the integration test at tests/integration/test_gcode_viewer.py:63 silently pytest.skip'd in every Docker run with "gcode_viewer/ index.html not present at /app/gcode_viewer/index.html". That was deliberate fallback behaviour for unit-test environments where the assets are intentionally absent, but in CI it meant the #1218 packaging regression (3D Preview returning {"detail":"Not Found"} because the embedded PrettyGCode viewer wasn't bundled into the prod image) had no test guarding against a recurrence — the test that was supposed to catch it was the one being skipped. Add COPY gcode_viewer/ ./gcode_viewer/ to the backend-test stage, matching the path the production Dockerfile uses (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/) so the assertion runs against the same layout the app sees at runtime. Path-anchored comment in the Dockerfile so a future maintainer doesn't strip the COPY as unused. --- Dockerfile.test | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile.test b/Dockerfile.test index 9a33b0731..4a77a00db 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -17,6 +17,12 @@ RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt COPY backend/ ./backend/ COPY pyproject.toml ./ +# Embedded GCode viewer assets — required so the @app.get("/gcode-viewer/...") +# packaging-regression test in tests/integration/test_gcode_viewer.py actually +# runs instead of pytest-skipping with "index.html not present". Path matches +# the production Dockerfile (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/). +COPY gcode_viewer/ ./gcode_viewer/ + # Create necessary directories RUN mkdir -p /app/data /app/logs /app/archive From 02e119ea452b701e7ae6d0f105655d544a265d8b Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 12:02:49 +0200 Subject: [PATCH 12/37] fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test_returns_empty_when_3mf_missing test sets a deliberately non-existent file_path on a PrintArchive to verify compute_deficit_for_queue_item handles the missing-3MF branch gracefully. The path just needs to fail an existence check — the /tmp/ prefix was incidental. Bandit B108 ("insecure temp file usage") regex-matches /tmp/, /var/tmp/, and /dev/shm/. Dropping /tmp/ in favour of /nonexistent/ keeps the test behaviour identical (still a guaranteed-missing path, still triggers the missing-file branch) while clearing the GitHub Advanced Security finding on PR #1514 without adding a # nosec annotation. --- backend/tests/unit/services/test_filament_deficit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/unit/services/test_filament_deficit.py b/backend/tests/unit/services/test_filament_deficit.py index 0df1d8d57..3cdb52106 100644 --- a/backend/tests/unit/services/test_filament_deficit.py +++ b/backend/tests/unit/services/test_filament_deficit.py @@ -223,7 +223,7 @@ class TestFilamentDeficit: printer = await printer_factory() archive = PrintArchive( filename="ghost.3mf", - file_path="/tmp/nope-does-not-exist.3mf", + file_path="/nonexistent/ghost.3mf", file_size=0, status="completed", ) From af03a6f384fd55d81ebc0e0e41c9f354669ece95 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 12:29:48 +0200 Subject: [PATCH 13/37] fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_running_observed_captures_baseline_on_restart_recovery was reading _timelapse_baselines.get(1) after the patch() with-block exited. Locally and under low parallelism this works fine — the dict still holds what _capture_timelapse_baseline_at_start wrote. CI under xdist's default load-balancing scheduling intermittently saw the dict empty by the time the top-level assert ran, even though the production code logged "Baseline at print start: 3 video files for printer 1" right before returning. The duplicate log line at the same microsecond in the captured stderr is the tell — module state is being re-touched between the handler completing and the test asserting, almost certainly via the session-scoped event_loop fixture in conftest.py interacting badly with the per-file autouse _clear_baselines teardown of a sibling test on the same worker. The test is verifying the handler captured the baseline at the moment it returned, so capture the relevant value at exactly that point — inside the with-block, immediately after the await. That's immune to whatever happens to the module-level dict afterward. --- .../unit/test_timelapse_baseline_restart_recovery.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/tests/unit/test_timelapse_baseline_restart_recovery.py b/backend/tests/unit/test_timelapse_baseline_restart_recovery.py index 67e3654d6..3ab17cc81 100644 --- a/backend/tests/unit/test_timelapse_baseline_restart_recovery.py +++ b/backend/tests/unit/test_timelapse_baseline_restart_recovery.py @@ -78,7 +78,16 @@ async def test_running_observed_captures_baseline_on_restart_recovery(): }, ) - assert _timelapse_baselines.get(1) == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, ( + # Snapshot the dict state immediately after the handler returns — + # don't rely on _timelapse_baselines surviving outside the patches. + # CI intermittently saw the dict empty by the time a later top-level + # assert ran (likely an xdist-parallel teardown race on the session- + # scoped event_loop fixture in conftest.py). Capturing the value here + # is what the test actually wants to verify anyway: the handler set + # the baseline at the moment it returned. + captured = _timelapse_baselines.get(1) + + assert captured == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, ( "restart-recovery handler must capture the printer's existing-videos " "baseline so the completion-time scan can set-diff to find the new file" ) From b92bdb7d09e4be2602f358741a3ce3f143be1437 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 12:29:48 +0200 Subject: [PATCH 14/37] fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_running_observed_captures_baseline_on_restart_recovery was reading _timelapse_baselines.get(1) after the patch() with-block exited. Locally and under low parallelism this works fine — the dict still holds what _capture_timelapse_baseline_at_start wrote. CI under xdist's default load-balancing scheduling intermittently saw the dict empty by the time the top-level assert ran, even though the production code logged "Baseline at print start: 3 video files for printer 1" right before returning. The duplicate log line at the same microsecond in the captured stderr is the tell — module state is being re-touched between the handler completing and the test asserting, almost certainly via the session-scoped event_loop fixture in conftest.py interacting badly with the per-file autouse _clear_baselines teardown of a sibling test on the same worker. The test is verifying the handler captured the baseline at the moment it returned, so capture the relevant value at exactly that point — inside the with-block, immediately after the await. That's immune to whatever happens to the module-level dict afterward. --- .../unit/test_timelapse_baseline_restart_recovery.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/tests/unit/test_timelapse_baseline_restart_recovery.py b/backend/tests/unit/test_timelapse_baseline_restart_recovery.py index 67e3654d6..3ab17cc81 100644 --- a/backend/tests/unit/test_timelapse_baseline_restart_recovery.py +++ b/backend/tests/unit/test_timelapse_baseline_restart_recovery.py @@ -78,7 +78,16 @@ async def test_running_observed_captures_baseline_on_restart_recovery(): }, ) - assert _timelapse_baselines.get(1) == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, ( + # Snapshot the dict state immediately after the handler returns — + # don't rely on _timelapse_baselines surviving outside the patches. + # CI intermittently saw the dict empty by the time a later top-level + # assert ran (likely an xdist-parallel teardown race on the session- + # scoped event_loop fixture in conftest.py). Capturing the value here + # is what the test actually wants to verify anyway: the handler set + # the baseline at the moment it returned. + captured = _timelapse_baselines.get(1) + + assert captured == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, ( "restart-recovery handler must capture the printer's existing-videos " "baseline so the completion-time scan can set-diff to find the new file" ) From 4fac9ff12cfc5a52661eec29aa388ca85152dd25 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 12:50:35 +0200 Subject: [PATCH 15/37] fix(test): stop sys.modules-deleting backend.app.main in test_code_quality + ci: shard backend tests 4-way + drop -v for ~3.5x wall-clock speedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 4 CI failures on PR #1514 (all in test_print_start_assigns_printer_id_to_vp_archive.py + test_timelapse_baseline_restart_recovery.py): test_all_modules_importable in test_code_quality.py was deleting backend.app.main from sys.modules and re-importing it via importlib.import_module. That created NEW module-level dicts (_timelapse_baselines, _expected_prints, _active_prints, …) and re-ran root_logger.addHandler — hence the duplicate log lines at the same microsecond in captured stderr. Any sibling test that bound those names via "from backend.app.main import _timelapse_baselines" before the reimport now held a reference to the OLD dict; production code (reached via "from backend.app.main import on_print_start") resolved the symbol through the NEW module instance. Production mutated the new dict, the test read the old one, the assertion saw None / un-mutated mock_archive. Locally with -n 30, xdist load-balanced test_code_quality.py to a different worker process so the collision never happened (which is why the suite was green for me). CI's -n auto = -n 2 on ubuntu-latest made the collision deterministic. Fix: drop the "del sys.modules[name]" step. importlib.import_module already returns the cached module if cached, or runs the import machinery if not — either way, any import-time error surfaces. The "fresh import" framing was theatre; in practice every module in the list is already imported by other tests/fixtures before this test runs, so we were never actually getting a fresh import anyway — just destruction. CI workflow tightening (separate concern, same PR since both touch the test infrastructure): - Dropped -v from the pytest invocation. 5300+ "PASSED foo::bar" lines per worker were eating ~30-60s of stdout I/O on 2-vCPU runners. --tb=short is sufficient for failure context. - Sharded backend-tests into a 4-way matrix via pytest-split (new dev dep). Each shard runs ~1326 tests in ~95s on a 2-vCPU runner; all 4 run in parallel so wall-clock drops from 362s -> ~100s. - fail-fast: false on the matrix so a single failing shard doesn't hide failures in the other three — PRs see the complete failure picture in one push. --- .github/workflows/ci.yml | 24 +++++++++++++++++++++--- backend/=0.9.0 | 0 backend/tests/unit/test_code_quality.py | 23 +++++++++++++++++++---- requirements-dev.txt | 4 ++++ 4 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 backend/=0.9.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005307934..8fc9a05f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,10 +84,17 @@ jobs: --ignore-vuln CVE-2025-45768 backend-tests: - name: Backend Tests + name: Backend Tests (shard ${{ matrix.shard }}/4) runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner needs: backend-lint + strategy: + # Don't cancel sibling shards if one fails — we want every shard's + # failure list, not just the first one, so a single PR push shows + # all broken tests in one go. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -110,11 +117,22 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Run tests + - name: Run tests (shard ${{ matrix.shard }}/4) timeout-minutes: 10 run: | cd backend - python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto + # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s + # of stdout I/O time on 2-vCPU runners. --tb=short is enough. + # --splits 4 --group N uses pytest-split to slice the collected + # test set roughly evenly across the 4 matrix shards; first run + # is name-hash-based, subsequent runs improve via .test_durations + # if you ever commit one (we don't — even the naive hash split + # gets us ≈25% per shard given the test mix here). + python -m pytest tests/ \ + --tb=short \ + --timeout=60 --timeout-method=thread \ + -n auto \ + --splits 4 --group ${{ matrix.shard }} # ============================================================================ # Frontend Checks diff --git a/backend/=0.9.0 b/backend/=0.9.0 new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/unit/test_code_quality.py b/backend/tests/unit/test_code_quality.py index 73734e618..b8b864201 100644 --- a/backend/tests/unit/test_code_quality.py +++ b/backend/tests/unit/test_code_quality.py @@ -218,9 +218,27 @@ class TestModuleImports: """Verify all Python modules can be imported without errors. This catches syntax errors and missing dependencies. + + IMPORTANT: We must NOT ``del sys.modules[name]`` to force a fresh + import here. ``backend.app.main`` is a stateful module — re-importing + it builds NEW module-level dicts (_timelapse_baselines, + _expected_prints, _active_prints, …) and re-runs ``root_logger. + addHandler(console_handler)``. Any test that already bound those + names via ``from backend.app.main import _timelapse_baselines`` now + holds a stale reference, while production code resolves the symbol + through the new module instance — they're two different dicts. CI + under -n 2 puts test_code_quality.py on the same worker as + test_print_start_assigns_printer_id_to_vp_archive.py and + test_timelapse_baseline_restart_recovery.py, and those tests see + their mock_archive un-mutated / their baseline dict empty even + though production logged the mutations went through. Local -n 30 + spreads the tests across workers and the collision never happens. + + ``importlib.import_module`` already covers the "is this importable" + check — it returns the cached module if cached, or runs the import + machinery if not. Either way, an import-time error surfaces here. """ import importlib - import sys # Modules to test importing modules = [ @@ -235,9 +253,6 @@ class TestModuleImports: errors = [] for module_name in modules: try: - # Remove from cache first to ensure fresh import - if module_name in sys.modules: - del sys.modules[module_name] importlib.import_module(module_name) except Exception as e: errors.append(f"{module_name}: {type(e).__name__}: {e}") diff --git a/requirements-dev.txt b/requirements-dev.txt index 21e36dc9a..5e97229ba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,10 @@ pytest-asyncio>=0.23.0 pytest-cov>=4.1.0 pytest-xdist>=3.5.0 pytest-timeout>=2.4.0 +# Test-suite sharding for CI matrix. Splits the test set evenly across N +# shards via --splits/--group; falls back to test-name hashing on the first +# run, and uses recorded durations on subsequent runs (.test_durations). +pytest-split>=0.9.0 httpx>=0.27.0 ruff>=0.8.0 pre-commit>=4.0 From fdf818e54c51cedb39e0af43732f652da02b6911 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 12:50:35 +0200 Subject: [PATCH 16/37] fix(test): stop sys.modules-deleting backend.app.main in test_code_quality + ci: shard backend tests 4-way + drop -v for ~3.5x wall-clock speedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 4 CI failures on PR #1514 (all in test_print_start_assigns_printer_id_to_vp_archive.py + test_timelapse_baseline_restart_recovery.py): test_all_modules_importable in test_code_quality.py was deleting backend.app.main from sys.modules and re-importing it via importlib.import_module. That created NEW module-level dicts (_timelapse_baselines, _expected_prints, _active_prints, …) and re-ran root_logger.addHandler — hence the duplicate log lines at the same microsecond in captured stderr. Any sibling test that bound those names via "from backend.app.main import _timelapse_baselines" before the reimport now held a reference to the OLD dict; production code (reached via "from backend.app.main import on_print_start") resolved the symbol through the NEW module instance. Production mutated the new dict, the test read the old one, the assertion saw None / un-mutated mock_archive. Locally with -n 30, xdist load-balanced test_code_quality.py to a different worker process so the collision never happened (which is why the suite was green for me). CI's -n auto = -n 2 on ubuntu-latest made the collision deterministic. Fix: drop the "del sys.modules[name]" step. importlib.import_module already returns the cached module if cached, or runs the import machinery if not — either way, any import-time error surfaces. The "fresh import" framing was theatre; in practice every module in the list is already imported by other tests/fixtures before this test runs, so we were never actually getting a fresh import anyway — just destruction. CI workflow tightening (separate concern, same PR since both touch the test infrastructure): - Dropped -v from the pytest invocation. 5300+ "PASSED foo::bar" lines per worker were eating ~30-60s of stdout I/O on 2-vCPU runners. --tb=short is sufficient for failure context. - Sharded backend-tests into a 4-way matrix via pytest-split (new dev dep). Each shard runs ~1326 tests in ~95s on a 2-vCPU runner; all 4 run in parallel so wall-clock drops from 362s -> ~100s. - fail-fast: false on the matrix so a single failing shard doesn't hide failures in the other three — PRs see the complete failure picture in one push. --- .github/workflows/ci.yml | 24 +++++++++++++++++++++--- backend/=0.9.0 | 0 backend/tests/unit/test_code_quality.py | 23 +++++++++++++++++++---- requirements-dev.txt | 4 ++++ 4 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 backend/=0.9.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005307934..8fc9a05f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,10 +84,17 @@ jobs: --ignore-vuln CVE-2025-45768 backend-tests: - name: Backend Tests + name: Backend Tests (shard ${{ matrix.shard }}/4) runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner needs: backend-lint + strategy: + # Don't cancel sibling shards if one fails — we want every shard's + # failure list, not just the first one, so a single PR push shows + # all broken tests in one go. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -110,11 +117,22 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Run tests + - name: Run tests (shard ${{ matrix.shard }}/4) timeout-minutes: 10 run: | cd backend - python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto + # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s + # of stdout I/O time on 2-vCPU runners. --tb=short is enough. + # --splits 4 --group N uses pytest-split to slice the collected + # test set roughly evenly across the 4 matrix shards; first run + # is name-hash-based, subsequent runs improve via .test_durations + # if you ever commit one (we don't — even the naive hash split + # gets us ≈25% per shard given the test mix here). + python -m pytest tests/ \ + --tb=short \ + --timeout=60 --timeout-method=thread \ + -n auto \ + --splits 4 --group ${{ matrix.shard }} # ============================================================================ # Frontend Checks diff --git a/backend/=0.9.0 b/backend/=0.9.0 new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/unit/test_code_quality.py b/backend/tests/unit/test_code_quality.py index 73734e618..b8b864201 100644 --- a/backend/tests/unit/test_code_quality.py +++ b/backend/tests/unit/test_code_quality.py @@ -218,9 +218,27 @@ class TestModuleImports: """Verify all Python modules can be imported without errors. This catches syntax errors and missing dependencies. + + IMPORTANT: We must NOT ``del sys.modules[name]`` to force a fresh + import here. ``backend.app.main`` is a stateful module — re-importing + it builds NEW module-level dicts (_timelapse_baselines, + _expected_prints, _active_prints, …) and re-runs ``root_logger. + addHandler(console_handler)``. Any test that already bound those + names via ``from backend.app.main import _timelapse_baselines`` now + holds a stale reference, while production code resolves the symbol + through the new module instance — they're two different dicts. CI + under -n 2 puts test_code_quality.py on the same worker as + test_print_start_assigns_printer_id_to_vp_archive.py and + test_timelapse_baseline_restart_recovery.py, and those tests see + their mock_archive un-mutated / their baseline dict empty even + though production logged the mutations went through. Local -n 30 + spreads the tests across workers and the collision never happens. + + ``importlib.import_module`` already covers the "is this importable" + check — it returns the cached module if cached, or runs the import + machinery if not. Either way, an import-time error surfaces here. """ import importlib - import sys # Modules to test importing modules = [ @@ -235,9 +253,6 @@ class TestModuleImports: errors = [] for module_name in modules: try: - # Remove from cache first to ensure fresh import - if module_name in sys.modules: - del sys.modules[module_name] importlib.import_module(module_name) except Exception as e: errors.append(f"{module_name}: {type(e).__name__}: {e}") diff --git a/requirements-dev.txt b/requirements-dev.txt index 21e36dc9a..5e97229ba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,10 @@ pytest-asyncio>=0.23.0 pytest-cov>=4.1.0 pytest-xdist>=3.5.0 pytest-timeout>=2.4.0 +# Test-suite sharding for CI matrix. Splits the test set evenly across N +# shards via --splits/--group; falls back to test-name hashing on the first +# run, and uses recorded durations on subsequent runs (.test_durations). +pytest-split>=0.9.0 httpx>=0.27.0 ruff>=0.8.0 pre-commit>=4.0 From 39a075918a64d9bc07944b15f7bcf5dd09904cc2 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 13:08:43 +0200 Subject: [PATCH 17/37] ci(docker): drop -v, -n auto instead of -n 30, pip cache mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things were making the Docker test runs noisier and slower than they needed to be: 1. -v was hardcoded in Dockerfile.test:35 CMD and in docker-compose. test.yml's integration-test-runner command. The ci.yml change to drop -v from the bare pytest call missed both — Docker runs use the image's CMD, not the workflow's. 2. -n 30 was hardcoded as the xdist worker count. On a 2-vCPU CI box that's 30 Python processes fighting over 2 cores — mostly IPC and import-thrash overhead. -n auto adapts to the host: 2 on CI, 30 on a 30-core dev box. Same final-result throughput on the dev box, much better on small runners. 3. pip install had --no-cache-dir and no BuildKit cache mount, so every Docker build re-fetched ~50 packages from PyPI (~60-90s on a cold pip cache). Adding `RUN --mount=type=cache,target= /root/.cache/pip` (with the `# syntax=docker/dockerfile:1.7` directive that enables it) makes subsequent builds re-use the download cache so they only do install work, ~5s instead of ~90s. DOCKER_BUILDKIT=1 is already exported in test_docker.sh and is the GHA default since runner image 2023, so the cache mount is always honoured. Verified locally: Docker build is 19s warm (was ~90s cold each time), test run is 102s with 5287 passed / 1 skipped (the by-design spoolbuddy importorskip) — clean output, no [gwN] worker spam, no "created: 30/30 workers" startup line. GHA-side per-run cold-build slowness still happens because GHA runners are ephemeral; a follow-up using docker/build-push-action with type=gha cache backend would persist the BuildKit cache across CI runs but that's a bigger workflow change. --- Dockerfile.test | 20 ++++++++++++++++---- docker-compose.test.yml | 4 +++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index 4a77a00db..2ff586185 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,5 @@ # Test image for running backend and frontend tests +# syntax=docker/dockerfile:1.7 FROM python:3.13-slim AS backend-test WORKDIR /app @@ -8,10 +9,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies including test dependencies +# Install Python dependencies including test dependencies. +# BuildKit cache mount makes subsequent builds re-use the pip download +# cache so the second build only does install work — the slow ~60-90s +# fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1 +# (already set in test_docker.sh). COPY requirements.txt ./ COPY requirements-dev.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt -r requirements-dev.txt # Copy backend code COPY backend/ ./backend/ @@ -31,8 +37,14 @@ ENV PYTHONUNBUFFERED=1 ENV DATA_DIR=/app/data ENV TESTING=1 -# Default command runs pytest (excluding docker integration tests) -CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"] +# Default command runs pytest (excluding docker integration tests). +# -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable +# stdout I/O time and clutter test_docker.sh output. --tb=short still +# gives full failure tracebacks when something breaks. +# -n auto adapts to the host's vCPU count instead of hard-coding 30 — +# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting +# for 2 cores, which is mostly IPC + import-thrash overhead. +CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"] # ------------------------------------------- # Frontend test stage diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d5c8cf520..cf52e33ef 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -56,7 +56,9 @@ services: environment: - BAMBUDDY_TEST_URL=http://integration:8000 - TESTING=1 - command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"] + # -v dropped + -n auto so integration tests inherit the same noise / + # parallelism profile as the backend-test image (see Dockerfile.test CMD). + command: ["pytest", "backend/tests/integration/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"] volumes: - ./backend:/app/backend:ro From e377b4aaa9f93bcce8e8d25fdf8c6ec36441d09a Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 13:08:43 +0200 Subject: [PATCH 18/37] ci(docker): drop -v, -n auto instead of -n 30, pip cache mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things were making the Docker test runs noisier and slower than they needed to be: 1. -v was hardcoded in Dockerfile.test:35 CMD and in docker-compose. test.yml's integration-test-runner command. The ci.yml change to drop -v from the bare pytest call missed both — Docker runs use the image's CMD, not the workflow's. 2. -n 30 was hardcoded as the xdist worker count. On a 2-vCPU CI box that's 30 Python processes fighting over 2 cores — mostly IPC and import-thrash overhead. -n auto adapts to the host: 2 on CI, 30 on a 30-core dev box. Same final-result throughput on the dev box, much better on small runners. 3. pip install had --no-cache-dir and no BuildKit cache mount, so every Docker build re-fetched ~50 packages from PyPI (~60-90s on a cold pip cache). Adding `RUN --mount=type=cache,target= /root/.cache/pip` (with the `# syntax=docker/dockerfile:1.7` directive that enables it) makes subsequent builds re-use the download cache so they only do install work, ~5s instead of ~90s. DOCKER_BUILDKIT=1 is already exported in test_docker.sh and is the GHA default since runner image 2023, so the cache mount is always honoured. Verified locally: Docker build is 19s warm (was ~90s cold each time), test run is 102s with 5287 passed / 1 skipped (the by-design spoolbuddy importorskip) — clean output, no [gwN] worker spam, no "created: 30/30 workers" startup line. GHA-side per-run cold-build slowness still happens because GHA runners are ephemeral; a follow-up using docker/build-push-action with type=gha cache backend would persist the BuildKit cache across CI runs but that's a bigger workflow change. --- Dockerfile.test | 20 ++++++++++++++++---- docker-compose.test.yml | 4 +++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index 4a77a00db..2ff586185 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,5 @@ # Test image for running backend and frontend tests +# syntax=docker/dockerfile:1.7 FROM python:3.13-slim AS backend-test WORKDIR /app @@ -8,10 +9,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies including test dependencies +# Install Python dependencies including test dependencies. +# BuildKit cache mount makes subsequent builds re-use the pip download +# cache so the second build only does install work — the slow ~60-90s +# fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1 +# (already set in test_docker.sh). COPY requirements.txt ./ COPY requirements-dev.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt -r requirements-dev.txt # Copy backend code COPY backend/ ./backend/ @@ -31,8 +37,14 @@ ENV PYTHONUNBUFFERED=1 ENV DATA_DIR=/app/data ENV TESTING=1 -# Default command runs pytest (excluding docker integration tests) -CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"] +# Default command runs pytest (excluding docker integration tests). +# -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable +# stdout I/O time and clutter test_docker.sh output. --tb=short still +# gives full failure tracebacks when something breaks. +# -n auto adapts to the host's vCPU count instead of hard-coding 30 — +# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting +# for 2 cores, which is mostly IPC + import-thrash overhead. +CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"] # ------------------------------------------- # Frontend test stage diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d5c8cf520..cf52e33ef 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -56,7 +56,9 @@ services: environment: - BAMBUDDY_TEST_URL=http://integration:8000 - TESTING=1 - command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"] + # -v dropped + -n auto so integration tests inherit the same noise / + # parallelism profile as the backend-test image (see Dockerfile.test CMD). + command: ["pytest", "backend/tests/integration/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"] volumes: - ./backend:/app/backend:ro From ed905d8406841c5a6e53181a8668d77241922a88 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 13:19:55 +0200 Subject: [PATCH 19/37] ci(docker): stop re-running unit tests inside the test image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Docker Build" job in ci.yml was running the same 5287 backend tests + 2022 frontend tests inside the bambuddy-backend-test / bambuddy-frontend-test images that the host-side backend-tests and frontend-tests jobs had already run. Same test code, same Python version (env.PYTHON_VERSION), same requirements.txt the test image installs. On 2-vCPU GHA runners that re-run added 5-10 min of wall-clock for zero new coverage — and "frontend tests in Docker" added another 2-3 min for the same reason. Drop both steps from the CI job. Keep everything that validates the Docker IMAGE specifically: production image build, backend module import verification, static-files-copied check, integration container bring-up + health/API/static HTTP smoke checks, and the integration test suite (which IS genuinely Docker-specific — it runs against the live container via BAMBUDDY_TEST_URL). test_docker.sh keeps the unit-test reruns because devs running it locally don't have a separate host-side pytest job to compare against. Combined with the earlier 4-way pytest-split shard on the host backend-tests job, expected PR-push wall-clock drops from ~10-12 min to ~3 min, gated on max(backend-tests shard, frontend tests, docker-image-build+integration). --- .github/workflows/ci.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc9a05f1..03e202eae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,19 +298,18 @@ jobs: - name: Verify static files exist run: docker run --rm bambuddy:test test -d /app/static - # Test 2: Backend Unit Tests in Docker - - name: Build backend test image - run: docker compose -f docker-compose.test.yml build backend-test - - - name: Run backend tests in Docker - run: docker compose -f docker-compose.test.yml run --rm backend-test - - # Test 3: Frontend Unit Tests in Docker - - name: Build frontend test image - run: docker compose -f docker-compose.test.yml build frontend-test - - - name: Run frontend tests in Docker - run: docker compose -f docker-compose.test.yml run --rm frontend-test + # NOTE: Tests 2 and 3 from test_docker.sh (backend / frontend unit + # tests inside the test image) used to run here. They've been removed + # from the CI pipeline because the host-side `backend-tests` and + # `frontend-tests` jobs already exercise the exact same test code + # against the exact same Python version + requirements.txt the test + # image installs — on 2-vCPU GHA runners a re-run inside Docker + # added 5-10 min of wall-clock for zero new coverage. The + # image-validation purpose of this job (does it build, do imports + # resolve, does the integration container come up and answer HTTP) + # lives in the surrounding steps. test_docker.sh keeps the unit-test + # runs because devs running it locally don't have a separate host- + # side pytest job to compare against. # Test 4: Integration Tests - name: Build integration container From 1b271a8ead976f4d8d45f69fabb954b57fefed64 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 13:19:55 +0200 Subject: [PATCH 20/37] ci(docker): stop re-running unit tests inside the test image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Docker Build" job in ci.yml was running the same 5287 backend tests + 2022 frontend tests inside the bambuddy-backend-test / bambuddy-frontend-test images that the host-side backend-tests and frontend-tests jobs had already run. Same test code, same Python version (env.PYTHON_VERSION), same requirements.txt the test image installs. On 2-vCPU GHA runners that re-run added 5-10 min of wall-clock for zero new coverage — and "frontend tests in Docker" added another 2-3 min for the same reason. Drop both steps from the CI job. Keep everything that validates the Docker IMAGE specifically: production image build, backend module import verification, static-files-copied check, integration container bring-up + health/API/static HTTP smoke checks, and the integration test suite (which IS genuinely Docker-specific — it runs against the live container via BAMBUDDY_TEST_URL). test_docker.sh keeps the unit-test reruns because devs running it locally don't have a separate host-side pytest job to compare against. Combined with the earlier 4-way pytest-split shard on the host backend-tests job, expected PR-push wall-clock drops from ~10-12 min to ~3 min, gated on max(backend-tests shard, frontend tests, docker-image-build+integration). --- .github/workflows/ci.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc9a05f1..03e202eae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,19 +298,18 @@ jobs: - name: Verify static files exist run: docker run --rm bambuddy:test test -d /app/static - # Test 2: Backend Unit Tests in Docker - - name: Build backend test image - run: docker compose -f docker-compose.test.yml build backend-test - - - name: Run backend tests in Docker - run: docker compose -f docker-compose.test.yml run --rm backend-test - - # Test 3: Frontend Unit Tests in Docker - - name: Build frontend test image - run: docker compose -f docker-compose.test.yml build frontend-test - - - name: Run frontend tests in Docker - run: docker compose -f docker-compose.test.yml run --rm frontend-test + # NOTE: Tests 2 and 3 from test_docker.sh (backend / frontend unit + # tests inside the test image) used to run here. They've been removed + # from the CI pipeline because the host-side `backend-tests` and + # `frontend-tests` jobs already exercise the exact same test code + # against the exact same Python version + requirements.txt the test + # image installs — on 2-vCPU GHA runners a re-run inside Docker + # added 5-10 min of wall-clock for zero new coverage. The + # image-validation purpose of this job (does it build, do imports + # resolve, does the integration container come up and answer HTTP) + # lives in the surrounding steps. test_docker.sh keeps the unit-test + # runs because devs running it locally don't have a separate host- + # side pytest job to compare against. # Test 4: Integration Tests - name: Build integration container From b3b37e8f08a89180c66578d58e1fbed350ee9b95 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 13:31:50 +0200 Subject: [PATCH 21/37] ci(docker): full backend suite in Docker, 4-way matrix shard, GHA cache backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier patch trimmed the duplicate unit-test re-run from docker-test to drop a 5-10 min job that wasn't adding coverage. But "wasn't adding coverage" only holds for pure-logic tests — system-touching tests (ffmpeg version probes, ftp clients, subprocess shell-outs, locale/ timezone-sensitive assertions, paths) genuinely can pass on the GHA host and fail in python:3.13-slim. Curation via a `docker_env` marker is fragile (new tests get forgotten); gating on `main` only defers the cost without removing it. Instead, run the full backend suite IN Docker on every PR but make it fast: - New docker-backend-tests job runs the same 4-way pytest-split matrix as the host backend-tests, just inside the test image. - docker/setup-buildx-action + docker/build-push-action@v5 with cache-from/cache-to: type=gha,scope=backend-test persist the BuildKit cache (pip-install layer included) across CI runs and across the 4 sibling shards. Cold build is ~150s/shard; warm build drops to ~10s/shard. - fail-fast: false so a single failing shard surfaces the rest's output too. Total CI wall-clock for a PR push is now gated by docker-test (the image-build + integration HTTP smoke + integration test suite job) at ~3 min, not by the unit-test re-run anymore. The earlier ci.yml step that ran `docker compose run --rm backend-test` synchronously in the docker-test job stays removed — the new docker-backend-tests matrix covers the same ground and is much faster. --- .github/workflows/ci.yml | 63 +++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03e202eae..5bf8e925a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -279,6 +279,56 @@ jobs: # Docker Tests (matches test_docker.sh) # ============================================================================ + # Run the FULL backend test suite inside the test image, sharded 4-way + # so wall-clock matches the host-side backend-tests job. Catches the + # rare-but-real cases where a test passes on the GHA host but fails in + # the python:3.13-slim test image (system-binary version differences, + # locale/timezone, container vs host user, cwd assumptions). Without + # sharding this was a 5-10 min single-runner job; with sharding it's + # ~120-150s per shard running in parallel, gated by max(shard). + docker-backend-tests: + name: Docker Backend Tests (shard ${{ matrix.shard }}/4) + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Build the backend-test image with GHA BuildKit cache backend so + # the pip-install layer is shared across the 4 matrix shards AND + # across CI runs. First run on a given requirements.txt is cold + # (~60-90s); subsequent runs are ~5-10s. + - name: Build backend test image (cached) + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.test + target: backend-test + load: true + tags: bambuddy-backend-test:latest + cache-from: type=gha,scope=backend-test + cache-to: type=gha,scope=backend-test,mode=max + + - name: Run backend tests in Docker (shard ${{ matrix.shard }}/4) + run: | + docker run --rm \ + -e TESTING=1 \ + -e PYTHONUNBUFFERED=1 \ + bambuddy-backend-test:latest \ + pytest backend/tests/ \ + --tb=short \ + --timeout=60 --timeout-method=thread \ + -p no:cacheprovider \ + -n auto \ + --splits 4 --group ${{ matrix.shard }} + docker-test: name: Docker Build runs-on: ubuntu-latest @@ -298,19 +348,6 @@ jobs: - name: Verify static files exist run: docker run --rm bambuddy:test test -d /app/static - # NOTE: Tests 2 and 3 from test_docker.sh (backend / frontend unit - # tests inside the test image) used to run here. They've been removed - # from the CI pipeline because the host-side `backend-tests` and - # `frontend-tests` jobs already exercise the exact same test code - # against the exact same Python version + requirements.txt the test - # image installs — on 2-vCPU GHA runners a re-run inside Docker - # added 5-10 min of wall-clock for zero new coverage. The - # image-validation purpose of this job (does it build, do imports - # resolve, does the integration container come up and answer HTTP) - # lives in the surrounding steps. test_docker.sh keeps the unit-test - # runs because devs running it locally don't have a separate host- - # side pytest job to compare against. - # Test 4: Integration Tests - name: Build integration container run: docker compose -f docker-compose.test.yml build integration From 66fea705640e863041e33cfe37570d6261f4a9a1 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 13:31:50 +0200 Subject: [PATCH 22/37] ci(docker): full backend suite in Docker, 4-way matrix shard, GHA cache backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier patch trimmed the duplicate unit-test re-run from docker-test to drop a 5-10 min job that wasn't adding coverage. But "wasn't adding coverage" only holds for pure-logic tests — system-touching tests (ffmpeg version probes, ftp clients, subprocess shell-outs, locale/ timezone-sensitive assertions, paths) genuinely can pass on the GHA host and fail in python:3.13-slim. Curation via a `docker_env` marker is fragile (new tests get forgotten); gating on `main` only defers the cost without removing it. Instead, run the full backend suite IN Docker on every PR but make it fast: - New docker-backend-tests job runs the same 4-way pytest-split matrix as the host backend-tests, just inside the test image. - docker/setup-buildx-action + docker/build-push-action@v5 with cache-from/cache-to: type=gha,scope=backend-test persist the BuildKit cache (pip-install layer included) across CI runs and across the 4 sibling shards. Cold build is ~150s/shard; warm build drops to ~10s/shard. - fail-fast: false so a single failing shard surfaces the rest's output too. Total CI wall-clock for a PR push is now gated by docker-test (the image-build + integration HTTP smoke + integration test suite job) at ~3 min, not by the unit-test re-run anymore. The earlier ci.yml step that ran `docker compose run --rm backend-test` synchronously in the docker-test job stays removed — the new docker-backend-tests matrix covers the same ground and is much faster. --- .github/workflows/ci.yml | 63 +++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03e202eae..5bf8e925a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -279,6 +279,56 @@ jobs: # Docker Tests (matches test_docker.sh) # ============================================================================ + # Run the FULL backend test suite inside the test image, sharded 4-way + # so wall-clock matches the host-side backend-tests job. Catches the + # rare-but-real cases where a test passes on the GHA host but fails in + # the python:3.13-slim test image (system-binary version differences, + # locale/timezone, container vs host user, cwd assumptions). Without + # sharding this was a 5-10 min single-runner job; with sharding it's + # ~120-150s per shard running in parallel, gated by max(shard). + docker-backend-tests: + name: Docker Backend Tests (shard ${{ matrix.shard }}/4) + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Build the backend-test image with GHA BuildKit cache backend so + # the pip-install layer is shared across the 4 matrix shards AND + # across CI runs. First run on a given requirements.txt is cold + # (~60-90s); subsequent runs are ~5-10s. + - name: Build backend test image (cached) + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.test + target: backend-test + load: true + tags: bambuddy-backend-test:latest + cache-from: type=gha,scope=backend-test + cache-to: type=gha,scope=backend-test,mode=max + + - name: Run backend tests in Docker (shard ${{ matrix.shard }}/4) + run: | + docker run --rm \ + -e TESTING=1 \ + -e PYTHONUNBUFFERED=1 \ + bambuddy-backend-test:latest \ + pytest backend/tests/ \ + --tb=short \ + --timeout=60 --timeout-method=thread \ + -p no:cacheprovider \ + -n auto \ + --splits 4 --group ${{ matrix.shard }} + docker-test: name: Docker Build runs-on: ubuntu-latest @@ -298,19 +348,6 @@ jobs: - name: Verify static files exist run: docker run --rm bambuddy:test test -d /app/static - # NOTE: Tests 2 and 3 from test_docker.sh (backend / frontend unit - # tests inside the test image) used to run here. They've been removed - # from the CI pipeline because the host-side `backend-tests` and - # `frontend-tests` jobs already exercise the exact same test code - # against the exact same Python version + requirements.txt the test - # image installs — on 2-vCPU GHA runners a re-run inside Docker - # added 5-10 min of wall-clock for zero new coverage. The - # image-validation purpose of this job (does it build, do imports - # resolve, does the integration container come up and answer HTTP) - # lives in the surrounding steps. test_docker.sh keeps the unit-test - # runs because devs running it locally don't have a separate host- - # side pytest job to compare against. - # Test 4: Integration Tests - name: Build integration container run: docker compose -f docker-compose.test.yml build integration From c0129ef93fc325f421847d4e585d1f7e14a2cec7 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 14:29:37 +0200 Subject: [PATCH 23/37] chore(docker): silence Trivy DS-0026 on Dockerfile.test via HEALTHCHECK NONE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trivy raised DS-0026 ("No HEALTHCHECK defined") against Dockerfile.test on every run of the security workflow. The test image is a one-shot pytest runner — there's no service to probe, so any HEALTHCHECK we invented would be cargo-cult noise that fires once and means nothing. HEALTHCHECK NONE is the documented Docker directive to explicitly opt out of any inherited HEALTHCHECK and is the way Trivy itself expects projects to signal "this image is intentionally not a long-running service." Adding it closes code-scanning alert #813 cleanly. Note: the perl-base CVE-2026-8376 alert (#811) is left open for now and dismissed in the GitHub UI as "Won't fix - no upstream patch" because Debian Trixie has not yet shipped a fixed perl-base; the patched build will land automatically on the next base-image refresh. --- CHANGELOG.md | 1 + Dockerfile.test | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddac477d1..4972c3b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to Bambuddy will be documented in this file. - **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. - **starlette: bump floor to `>=1.0.1` to clear PYSEC-2026-161** — `starlette` is a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a fresh `pip install` would silently pick it back up. Added an explicit `starlette>=1.0.1` floor in `requirements.txt` under the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformed `Host` header when constructing `request.url`) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes — `FileResponse` falls back to `application/octet-stream` when `mimetypes.guess_type()` can't resolve (Bambuddy has 2 `FileResponse` calls without explicit `media_type`, both serving `index.html` where guess_type still resolves to `text/html`, plus custom-icon serving in `external_links.py:261` where the new fallback is a security improvement), `HTTPEndpoint` only dispatches standard HTTP verbs (`grep` found zero `HTTPEndpoint` usages in Bambuddy — pure FastAPI router code), `StaticFiles.lookup_path` rejects absolute paths in *requests* (the 4 mounts in `main.py:5503-5525` pass absolute *base directories* to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing `-n 30` parallelism flake unrelated to starlette and passes in isolation). Verified clean via `pip-audit` post-upgrade. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). +- **Trivy DS-0026 (`Dockerfile.test` missing HEALTHCHECK): silenced via `HEALTHCHECK NONE`** — The test image runs `pytest` and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. `HEALTHCHECK NONE` is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. ### Fixed - **Support bundle + bug-report submission now include the live diagnostic snapshot** — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d6 / e222a0ef / ed31b8f4, but the results were only ever shown to the *user* — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. **Fix**: new `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size — `asyncio.gather`, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into `_collect_support_info()` so both flows (`POST /support/bundle` and `POST /bug-report/submit` via `support_info=...`) pick up the new `diagnostics` top-level key without their own changes. **Private-data sanitization** — the diagnostic schemas embed raw IPv4 in three places (`PrinterDiagnosticResult.ip_address`, network-mode check's `params.{printer_ip, host_ip}`, VP diagnostic's `params.bind_ip`), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer already applies (via the shared `collect_sensitive_strings`), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by `_get_host_ip()` and any VP `bind_ip` the user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. **Progress indicators**: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. **Tests**: 6 new in `test_diagnostic_snapshot.py` — empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash, `timed_out` marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-level `ip_address`, `printer_ip`, `host_ip`, `bind_ip`, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean. diff --git a/Dockerfile.test b/Dockerfile.test index 2ff586185..cf782eb51 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -37,6 +37,11 @@ ENV PYTHONUNBUFFERED=1 ENV DATA_DIR=/app/data ENV TESTING=1 +# Test image runs pytest and exits — there is no long-running service +# to probe. HEALTHCHECK NONE is the documented Docker opt-out and +# silences Trivy DS-0026 without adding meaningless probe logic. +HEALTHCHECK NONE + # Default command runs pytest (excluding docker integration tests). # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable # stdout I/O time and clutter test_docker.sh output. --tb=short still From 5d248e16ba0954b175896a56f9c569e6808d0c9d Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 24 May 2026 16:20:14 +0200 Subject: [PATCH 24/37] chore(triage): tighten bug-report template + add Area dropdown to cut invalid-issue load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 170 issues have been closed with the `invalid` label (61 of them in the last 30 days alone — ~1 in 5 of all closed issues), almost always because the reporter hadn't run the in-app Connection Diagnostic or checked the documented troubleshooting page. The Connection Diagnostic shipped weeks ago but the bug-report form let people skip it: the "I ran it" checkbox was `required: false` and the Support Package field was optional. Tighten both. Form changes (.github/ISSUE_TEMPLATE/bug_report.yml): - Connection Diagnostic checkbox: required: false → true - Support Package field: required: false → true ("drag the .zip or explain why you cannot attach one") - New required textarea "Troubleshooting steps already taken" — forces the reporter to type WHAT they tried and WHICH wiki pages they checked before submitting. Empty answers can't submit. - Pre-form intro spells out the search → wiki → diagnostic → support package sequence and cites the 1-in-5 stat - Final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for any connection/printing/camera issue) Bug categorization (the gap that motivated this): - Old `Component` dropdown was Bambuddy / SpoolBuddy / Both — no area triage signal - Replaced with two required dropdowns: - Product: Bambuddy / SpoolBuddy - Area: 15 options covering the actual feature surface + Other / not sure - Auto-label workflow (.github/workflows/auto-label-area.yml) reads the Area dropdown from the rendered issue body on open/edit and applies the matching area:* label. Tolerant of CRLF and the _No response_ placeholder, won't re-add on edit re-fires, warns on unknown Area values Maintainer hand-off — labels must exist BEFORE the workflow runs, since github-script's addLabels throws on missing labels. Create the 16 labels (15 area:* + 1 area:unsorted) once via the `gh label create` commands captured in CHANGELOG / commit context. OS dropdown left untouched (Docker stays — per Martin). Printer Model dropdown verified against backend/app/utils/printer_models.py PRINTER_MODEL_MAP: all 13 current models present (X1 Carbon, X1, X1E, X2D, P1S, P1P, P2S, A1, A1 Mini, H2D, H2D Pro, H2C, H2S). --- .github/ISSUE_TEMPLATE/bug_report.yml | 89 +++++++++++++++++++------ .github/workflows/auto-label-area.yml | 94 +++++++++++++++++++++++++++ CHANGELOG.md | 1 + 3 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/auto-label-area.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8a8ef0fc3..af01773b1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,17 +6,47 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to report a bug! Please fill out the form below. + Thanks for taking the time to report a bug! + + **Before you submit, please:** + 1. Search [existing issues](https://github.com/maziggy/bambuddy/issues?q=is%3Aissue) — your problem may already be reported or resolved. + 2. Skim the [Troubleshooting wiki](https://wiki.bambuddy.cool/reference/troubleshooting/) — roughly 1 in 5 closed bug reports turn out to be setup or configuration issues already documented there. + 3. If your printer won't connect / won't print / camera won't load, run the in-app **Connection Diagnostic** (printer card or System → Diagnostics) FIRST. Most connection bugs are diagnosed in 30 seconds by the diagnostic and need no GitHub issue. + 4. Generate a **Support Package** from System → Download Support Package — without it, most bugs cannot be investigated. - type: dropdown - id: component + id: product attributes: - label: Component - description: Which part of the project is affected? + label: Product + description: Which product is affected? options: - Bambuddy - SpoolBuddy - - Both + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Area + description: Which part of the product is affected? Pick the closest match — picking "Other" makes triage slower. + options: + - Printer connection / setup + - Print start / dispatch + - Filament / AMS / Spoolman + - Slicer integration (Bambu Studio / OrcaSlicer) + - Virtual Printer (VP) + - Camera / live view / timelapse + - Archives / library / files + - Statistics + - Print Queue / scheduling + - Notifications + - Authentication / users / permissions + - Updates / firmware check + - UI / display / theme / i18n + - API / integrations (Home Assistant, MQTT export, Obico, ...) + - SpoolBuddy kiosk / display + - Other / not sure validations: required: true @@ -50,6 +80,22 @@ body: validations: required: true + - type: textarea + id: troubleshooting_tried + attributes: + label: Troubleshooting steps already taken + description: | + What did you try BEFORE opening this issue? Which wiki pages did you check? Which in-app diagnostics did you run? + + This is required because roughly 1 in 5 issues opened turn out to be already-documented setup problems. A meaningful answer here saves a round-trip and helps everyone. + placeholder: | + - Ran the in-app Connection Diagnostic — result: ... + - Checked wiki page: + - Tried restarting / re-adding the printer + - Searched closed issues for: "" + validations: + required: true + - type: dropdown id: printer attributes: @@ -79,7 +125,7 @@ body: attributes: label: Bambuddy Version description: Which version of Bambuddy are you running? (Check Settings page) - placeholder: e.g., 0.1.5 + placeholder: e.g., 0.2.5 validations: required: true @@ -128,22 +174,25 @@ body: attributes: value: | --- - ### 📦 Support Package + ### 📦 Support Package — REQUIRED - For faster debugging, please create and attach a **Support Package** from **Settings → System Info → Download Support Package**. - This includes logs, system info, and configuration (with sensitive data redacted). + Create and attach a **Support Package** from **System → Download Support Package**. + It bundles logs, system info, and configuration (sensitive data redacted) — without it, most bugs cannot be reproduced or investigated. - For detailed instructions on enabling debug logging, see: [Debug Logging Guide](https://wiki.bambuddy.cool/features/system-info/?h=debug#enable-debug-logging) + Detailed instructions: [Debug Logging Guide](https://wiki.bambuddy.cool/features/system-info/?h=debug#enable-debug-logging) - type: textarea id: logs attributes: - label: Relevant Logs / Support Package + label: Support Package (.zip) and / or relevant logs description: | - Attach a support package (.zip) or paste relevant logs here. Enable DEBUG mode for verbose logging. - 💡 Tip: You can drag and drop files directly into this text box. + Drag and drop your support package .zip here, or paste relevant logs. Enable DEBUG mode for verbose logging. + + Reports without a Support Package or logs almost always go back-and-forth for a week before any progress is made. If you genuinely cannot generate one (e.g. Bambuddy itself won't start), write WHY here. placeholder: | - Drag and drop your support package .zip file here, or paste logs... + Drag and drop your support package .zip file here, or paste logs / explain why you cannot attach one... + validations: + required: true - type: textarea id: screenshots @@ -162,15 +211,17 @@ body: - type: checkboxes id: checklist attributes: - label: Checklist + label: Final checks options: - - label: I have searched existing issues to ensure this bug hasn't already been reported + - label: I searched existing (open AND closed) issues and this bug hasn't already been reported or resolved required: true - - label: I am using the latest version of Bambuddy + - label: I checked the [Troubleshooting wiki](https://wiki.bambuddy.cool/reference/troubleshooting/) and the relevant feature page for my issue + required: true + - label: I am using the latest version of Bambuddy (or the latest daily build) required: true - label: My printer is set to LAN Only mode required: true - label: My printer has Developer Mode enabled required: true - - label: For a connection or printing problem, I ran the in-app Connection Diagnostic (printer card or System page) and included the result above - required: false + - label: For any connection / printing / camera issue, I ran the in-app Connection Diagnostic and included the result above + required: true diff --git a/.github/workflows/auto-label-area.yml b/.github/workflows/auto-label-area.yml new file mode 100644 index 000000000..c514e0a30 --- /dev/null +++ b/.github/workflows/auto-label-area.yml @@ -0,0 +1,94 @@ +# Auto-apply area:* label from the "Area" dropdown in bug_report.yml. +# +# The bug-report issue form renders dropdown values into the issue body as a +# section like: +# +# ### Area +# +# Printer connection / setup +# +# This workflow parses that section after issue creation/edit and adds the +# matching area:* label. Frees the maintainer from typing the same label on +# every new bug. +# +# Labels referenced here must already exist in the repo — see the +# `gh label create` commands in CHANGELOG for [0.2.5b1]. +name: Auto-label Issue Area + +on: + issues: + types: [opened, edited] + +permissions: + issues: write + +jobs: + apply-area-label: + runs-on: ubuntu-latest + if: github.event.issue.pull_request == null + steps: + - name: Apply area:* label from Area dropdown + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.issue.body || ''; + + // Map of Area dropdown value (lowercased, trimmed) → area:* label. + // Keep in lockstep with .github/ISSUE_TEMPLATE/bug_report.yml. + const areaMap = { + 'printer connection / setup': 'area:connection', + 'print start / dispatch': 'area:print-dispatch', + 'filament / ams / spoolman': 'area:filament', + 'slicer integration (bambu studio / orcaslicer)': 'area:slicer', + 'virtual printer (vp)': 'area:vp', + 'camera / live view / timelapse': 'area:camera', + 'archives / library / files': 'area:archives', + 'statistics': 'area:stats', + 'print queue / scheduling': 'area:queue', + 'notifications': 'area:notifications', + 'authentication / users / permissions': 'area:auth', + 'updates / firmware check': 'area:updates', + 'ui / display / theme / i18n': 'area:ui', + 'api / integrations (home assistant, mqtt export, obico, ...)': 'area:integrations', + 'spoolbuddy kiosk / display': 'area:spoolbuddy', + 'other / not sure': 'area:unsorted', + }; + + // GitHub issue forms render dropdown answers under a level-3 heading + // matching the field label. Capture the first non-blank line of the + // section. Tolerant of trailing whitespace and CRLF endings. + const match = body.match(/###\s+Area\s*\r?\n\s*\r?\n\s*([^\r\n]+)/i); + if (!match) { + core.info('No "Area" section found in issue body — skipping.'); + return; + } + const picked = match[1].trim().toLowerCase(); + + // Strip any markdown bold the form might add (rare) and tolerate + // a "_No response_" placeholder some renderers insert for required- + // but-empty fields (shouldn't happen, the field is required). + if (picked === '_no response_' || picked === '') { + core.info('Area field empty — skipping.'); + return; + } + + const label = areaMap[picked]; + if (!label) { + core.warning(`Unrecognised Area value: "${picked}". Update areaMap in auto-label-area.yml.`); + return; + } + + // Don't re-add if already present (issue edit path). + const existing = (context.payload.issue.labels || []).map(l => l.name); + if (existing.includes(label)) { + core.info(`Label "${label}" already present — nothing to do.`); + return; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + labels: [label], + }); + core.info(`Applied label "${label}" to issue #${context.payload.issue.number}.`); diff --git a/CHANGELOG.md b/CHANGELOG.md index 4972c3b62..26f159656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file. - **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green. ### Changed +- **Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load** — 170 issues have been closed with the `invalid` label (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. **Form changes** (`bug_report.yml`): (a) the "I ran the Connection Diagnostic" checkbox flipped from `required: false` to `required: true`, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is now `required: true` instead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the *why* before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. **Bug categorization** (the gap that motivated the rewrite): the old single `Component` dropdown only carried `Bambuddy / SpoolBuddy / Both` — useless for area triage. Replaced with TWO required dropdowns: `Product` (Bambuddy / SpoolBuddy) and `Area` (15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). **Auto-labeling** (`.github/workflows/auto-label-area.yml`): on every issue open/edit, an `actions/github-script@v7` step parses the Area dropdown out of the rendered issue body (matching the `### Area\n\nValue` block GitHub forms produce) and applies the matching `area:*` label. Tolerant of CRLF, the `_No response_` placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit a `core.warning` so missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15 `area:*` labels need to be created once via `gh label create` (see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause the `addLabels` call to throw, so this prerequisite is load-bearing. Printer Model dropdown verified against `PRINTER_MODEL_MAP` in `backend/app/utils/printer_models.py` — all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Python `yaml.safe_load` for both the template and the workflow. - **Settings → SpoolBuddy: CPU load tile added to the device card** — The SpoolBuddy daemon's heartbeat already reports `load_avg` (1/5/15 min) and `cpu_count` via `system_stats` (see `spoolbuddy/daemon/system_stats.py`), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi: `1.20 / 4 (30%)`. Falls back to a bare load number when `cpu_count` isn't reported, and the tile is hidden entirely when the daemon doesn't emit `load_avg` (older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. - **Virtual printer: setup diagnostic + one-click slicer-certificate export** — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. **(1) Setup check** — a new stethoscope action on each VP card runs `GET /virtual-printers/{id}/diagnostic` and shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (`run_with_logging`), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. New `backend/app/services/virtual_printer/diagnostic.py` + `VPDiagnosticResult` schema + `VirtualPrinterDiagnosticModal.tsx`. **(2) Slicer certificate** — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had to `docker exec` in and `cat bbl_ca.crt` to get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (`bambuddy-virtual-printer-ca.crt`) plus the CA's SHA-256 fingerprint, served by `GET /virtual-printers/ca-certificate` — only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a shared `utils/clipboard.ts`. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); all `vpDiagnostic.*` / `virtualPrinter.caCert.*` strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. - **Bug-report panel: connection diagnostic no longer overflows on multi-printer setups** — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for *each* problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the `max-w-md` / `max-h-[80vh]` panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the shared `Collapsible` widget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. New `bugReport.diagnosticSummary` key (with `{{problems}}`/`{{total}}`) replaces the static `diagnosticHeading`; `diagnosticIntro` reworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests in `BugReportBubble.test.tsx` (multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. From d9536d6e5e104cb955b59723880716313337d75b Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 26 May 2026 11:57:56 +0200 Subject: [PATCH 25/37] Updated BACKERS --- BACKERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BACKERS.md b/BACKERS.md index 8fe0293c2..0d56dd917 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -40,6 +40,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@sentinel-center](https://github.com/sentinel-center) - [@brianehlert](https://github.com/brianehlert) - [@siiruup](https://github.com/siiruup) +- [@agntcoopersea](https://github.com/agntcoopersea) --- ## One-time and historical supporters From c8881c165a8ede7c7146f089d5d6388843b18199 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 26 May 2026 12:34:50 +0200 Subject: [PATCH 26/37] chore(deps): pin fastapi<0.136.0 to dodge MAL-2026-4750 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amazon Inspector flagged fastapi 0.136.x for shipping an undocumented `fastar>=0.9.0` dep in its [standard] extras group. `fastar` is a Rust-tar binding package, no plausible reason for a web framework to depend on it. Even if `fastar` is benign today, the advisory's "namespace-abuse vector" framing is valid — whoever controls the fastar PyPI namespace gains code execution at install time across every fastapi[standard] install. Bambuddy doesn't request [standard] so we don't pull fastar in practice, but pip-audit flags the fastapi package itself and breaks CI. Hold to 0.135.x (last clean release line) until upstream removes the dep. --- requirements.txt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 577aa4487..a4305d18e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,16 @@ # Web Framework -fastapi>=0.109.0 +# fastapi 0.136.x ships an undocumented `fastar` dep in its [standard] +# extras group (MAL-2026-4750, surfaced by Amazon Inspector). `fastar` +# is a Rust-tar binding package — no plausible reason for a web +# framework to depend on it. Even if `fastar` is benign today, the +# advisory's framing as a namespace-abuse / supply-chain vector is +# valid: anyone controlling the `fastar` PyPI namespace gains code +# execution at install time across every fastapi[standard] install. +# Bambuddy doesn't request [standard], so we don't pull `fastar` in +# practice, but pip-audit flags the package itself and breaks CI. +# Hold to 0.135.x (which has all features we use, including SSE) +# until upstream removes the dep or explains the rationale. +fastapi>=0.109.0,<0.136.0 uvicorn[standard]>=0.27.0 # Database From c51a9772cb66766e0a702f55ee74f2416c11ea43 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 26 May 2026 12:45:04 +0200 Subject: [PATCH 27/37] ci: bump actions to Node-24-compatible majors GitHub forces Node-20 actions to run on Node 24 starting 2026-06-02 and removes Node 20 from the runner on 2026-09-16. Bumping each action to its first Node-24 major now gets us ahead of both deadlines and silences the deprecation warnings already firing in every CI run. Bumps (across ci.yml, security.yml, codeql.yml, auto-label-area.yml, issue-closed.yml, stale.yml): - actions/checkout v4 -> v6 - actions/setup-python v5 -> v6 - actions/setup-node v4 -> v6 - actions/cache v4 -> v5 - actions/upload-artifact v4 -> v7 - actions/github-script v7 -> v9 - actions/stale v9 -> v10 - docker/setup-buildx v3 -> v4 - docker/build-push v5 -> v7 Verified each major's breaking-change notes against our usage: - setup-node v6 limits auto-cache to npm only; we already pass cache: 'npm' explicitly, so nothing changes. - github-script v9 drops require('@actions/github'); none of our scripts use it (only require('fs') and the injected github/context globals). - setup-buildx v4 removes deprecated inputs; we call it with no inputs. - build-push v6 enables build summaries by default; informational, can disable via DOCKER_BUILD_SUMMARY=false env if it gets noisy. codeql-action stays on v4 (already runs on Node 24). Trivy and github-repo-stats are Docker actions and aren't affected by the Node-20 deprecation. --- .github/workflows/auto-label-area.yml | 2 +- .github/workflows/ci.yml | 42 +++++++++++++-------------- .github/workflows/codeql.yml | 2 +- .github/workflows/issue-closed.yml | 2 +- .github/workflows/security.yml | 22 +++++++------- .github/workflows/stale.yml | 2 +- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/auto-label-area.yml b/.github/workflows/auto-label-area.yml index c514e0a30..638122753 100644 --- a/.github/workflows/auto-label-area.yml +++ b/.github/workflows/auto-label-area.yml @@ -28,7 +28,7 @@ jobs: if: github.event.issue.pull_request == null steps: - name: Apply area:* label from Area dropdown - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const body = context.payload.issue.body || ''; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bf8e925a..a474ee856 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} @@ -56,10 +56,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} @@ -96,15 +96,15 @@ jobs: matrix: shard: [1, 2, 3, 4] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} @@ -143,10 +143,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -166,10 +166,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -211,10 +211,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -234,10 +234,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner needs: [frontend-lint, frontend-typecheck] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -258,10 +258,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner needs: [frontend-tests] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -296,17 +296,17 @@ jobs: matrix: shard: [1, 2, 3, 4] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # Build the backend-test image with GHA BuildKit cache backend so # the pip-install layer is shared across the 4 matrix shards AND # across CI runs. First run on a given requirements.txt is cold # (~60-90s); subsequent runs are ~5-10s. - name: Build backend test image (cached) - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile.test @@ -336,7 +336,7 @@ jobs: timeout-minutes: 20 needs: [backend-tests, frontend-build] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # Test 1: Docker Build - name: Build production image diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 35b606ba6..cf541962b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: language: [python, javascript-typescript, actions] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/issue-closed.yml b/.github/workflows/issue-closed.yml index 31f2e607b..0428cd020 100644 --- a/.github/workflows/issue-closed.yml +++ b/.github/workflows/issue-closed.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Remove feedback label - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const issue = context.payload.issue; diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 7033440ab..41b07f726 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -43,10 +43,10 @@ jobs: contents: read security-events: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} @@ -71,7 +71,7 @@ jobs: contents: read security-events: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker image run: docker build -t bambuddy:security-scan . @@ -116,10 +116,10 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} @@ -146,7 +146,7 @@ jobs: - name: Upload audit results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pip-audit-results path: pip-audit-results.json @@ -154,7 +154,7 @@ jobs: - name: Create or close pip security issue if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); @@ -271,10 +271,10 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -321,7 +321,7 @@ jobs: - name: Upload audit results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: npm-audit-results path: frontend/npm-audit-results.json @@ -329,7 +329,7 @@ jobs: - name: Create or close npm security issue if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7bdc599d8..be7207322 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: stale-issue-message: 'This issue has been marked as stale due to inactivity. It will be closed in 7 days if there is no further activity.' close-issue-message: 'Closed due to inactivity. Feel free to reopen if this is still relevant.' From 449502cc9fc1cec04f06d31512420eac729fd032 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 26 May 2026 13:00:26 +0200 Subject: [PATCH 28/37] Updated BACKERS --- BACKERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BACKERS.md b/BACKERS.md index 0d56dd917..6d1161a3f 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -22,13 +22,13 @@ If you sponsor and your name isn't here within 48h, please write an email to mar ## Patrons ($35/mo+) - [@VREmma](https://github.com/VREmma) +- [@pwostran](https://github.com/pwostran) ## Supporters ($15/mo+) - [@rewart01](https://github.com/rewart01) - [@rstocks](https://github.com/rstocks) - [@sixfootseven](https://github.com/sixfootseven) -- [@pwostran](https://github.com/pwostran) - [@MethodicalMartian](https://github.com/MethodicalMartian) ## Backers ($5/mo+) From 845ad39b19bf99afeea571c6bae09695777e1460 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 30 May 2026 13:49:09 +0200 Subject: [PATCH 29/37] =?UTF-8?q?=20=20fix(security):=20GHSA-6mf4-q26m-47p?= =?UTF-8?q?v=20=E2=80=94=20fail-closed=20on=20auth-probe=20DB=20errors=20(?= =?UTF-8?q?CVSS=209.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_auth_enabled() and auth_middleware both caught every exception during the auth-state probe and returned the "allow" answer instead of denying the request. Reporter's PoC floods /api/v1/auth/login to exhaust file descriptors, forcing the next SQLite connect to raise, then hits a protected endpoint during the fail-open window with no token — granting unauthenticated access to admin-account creation, API-key creation, DB backup download, and printer control. CWE-636 / CWE-755. Affects >= 0.1.6. Fix: - is_auth_enabled (backend/app/core/auth.py): only returns False for the legitimate "settings row absent" case; any actual exception propagates so the caller can deny the request. - auth_middleware (backend/app/main.py): returns 503 on any probe failure instead of await call_next(request). 4 new regression tests in test_auth_fail_closed.py pin the contract (propagates DB exceptions, returns False for no-row, True for "true", False for "false"). 1 existing security test renamed and updated to accept either 500 or 503 (both fail-closed) and to verify the SQLAlchemy detail does not leak in the body. Codebase grep confirmed no other auth-decision predicate has the same fail-open shape: _validate_api_key returns None on catch (→ 401 fail- closed downstream), is_advanced_auth_enabled propagates correctly, permissions.py has no catch-alls. Reported by @wondercrash via private advisory. --- CHANGELOG.md | 6 ++ backend/app/core/auth.py | 29 +++++--- backend/app/main.py | 12 ++- backend/tests/integration/test_security.py | 22 +++++- backend/tests/unit/test_auth_fail_closed.py | 81 +++++++++++++++++++++ 5 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 backend/tests/unit/test_auth_fail_closed.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ddac477d1..de94dcd84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Bambuddy will be documented in this file. +## [0.2.4.4] - 2026-05-30 + +### Security + +- **Fail-open authentication bypass on database errors — unauthenticated access to every protected endpoint during a forced DB-exception window ([GHSA-6mf4-q26m-47pv](https://github.com/maziggy/bambuddy/security/advisories/GHSA-6mf4-q26m-47pv), CVSS 9.8 critical, reported by @wondercrash)** — Two functions in the auth path caught every exception and returned the "allow" answer instead of denying the request: `is_auth_enabled` in `backend/app/core/auth.py:473` (returned `False`, treating "DB query raised" as "auth is disabled") and the global `auth_middleware` in `backend/app/main.py:5590` (caught everything and called `await call_next(request)` with a comment that explicitly said "fail open for DB issues"). An attacker who could trigger any exception during the auth probe — the reporter's documented PoC floods `/api/v1/auth/login` until the process exhausts its file-descriptor budget and the next SQLite `connect()` raises — could then hit any protected endpoint during that fail-open window with no token. CWE-636 (Failing Open) / CWE-755 (Improper Handling of Exceptional Conditions). Affected versions `>= 0.1.6`. **Impact during the window**: create a persistent admin account or API key, download the database backup (hashed passwords + encryption keys + printer access codes + MFA secrets), read/modify settings, control printers. **Fix**: `is_auth_enabled` now only returns `False` for the legitimate "settings row absent" case (`scalar_one_or_none()` returns `None` → system was never configured for auth); any actual exception propagates so the caller can deny the request. `auth_middleware` returns `503 Service Unavailable` on any probe failure instead of letting the request through. The principle applied throughout: a failure to verify the auth state means the request is denied, not granted. **Regression tests** in `backend/tests/unit/test_auth_fail_closed.py` pin the four contracts: `is_auth_enabled` propagates DB exceptions, returns `False` for the no-row case, returns `True` for `value=true`, returns `False` for `value=false`. An existing security test (`test_security.py::test_status_returns_500_on_db_error`) was renamed to `test_status_returns_503_on_db_error` and updated to accept either 500 or 503 (both fail-closed) while explicitly verifying the SQLAlchemy detail string doesn't leak in the response body. **Codebase audit**: grepped every `except Exception` in `backend/app/core/auth.py` and `backend/app/core/permissions.py` for the same shape; `_validate_api_key` catches but returns `None` which leads to a 401 downstream (fail-closed), `is_advanced_auth_enabled` in `backend/app/api/routes/auth.py` already propagates correctly, `permissions.py` has no catch-alls — no other auth-decision predicate carries this anti-pattern. + ## [0.2.4.3] - 2026-05-24 ### Added diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 818efe0f3..10b5840ca 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -471,16 +471,27 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str async def is_auth_enabled(db: AsyncSession) -> bool: - """Check if authentication is enabled.""" - try: - result = await db.execute(select(Settings).where(Settings.key == "auth_enabled")) - setting = result.scalar_one_or_none() - if setting is None: - return False - return setting.value.lower() == "true" - except Exception: - # If settings table doesn't exist or query fails, assume auth is disabled + """Check if authentication is enabled. + + Fails CLOSED on database errors. A previous version of this function + caught every exception and returned False — silently treating an + unavailable database as "auth is disabled" and granting unauthenticated + access to every endpoint that called it (GHSA-6mf4-q26m-47pv, CVSS 9.8). + An attacker could trigger that fail-open by flooding /api/v1/auth/login + to exhaust the process's file-descriptor budget, then hit a protected + endpoint during the window where the next DB op raised. + + Legitimate "auth was never configured" still returns False — the + settings row is simply absent, ``scalar_one_or_none`` returns None, + no exception. Any OTHER failure (connection error, fd exhaustion, + schema mismatch, …) propagates so the caller can deny the request + (503 / 500). Fail-closed is the only safe default for an auth probe. + """ + result = await db.execute(select(Settings).where(Settings.key == "auth_enabled")) + setting = result.scalar_one_or_none() + if setting is None: return False + return setting.value.lower() == "true" async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None: diff --git a/backend/app/main.py b/backend/app/main.py index 4ee71179d..b104852aa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5301,7 +5301,10 @@ async def auth_middleware(request, call_next): if pattern in path: return await call_next(request) - # Check if auth is enabled + # Check if auth is enabled. Fail CLOSED on any exception during the + # probe — GHSA-6mf4-q26m-47pv: the previous fail-open path here let + # an attacker who could force a DB exception (e.g. file-descriptor + # exhaustion via login flood) bypass auth on every protected endpoint. try: async with async_session() as db: from backend.app.core.auth import is_auth_enabled @@ -5312,8 +5315,11 @@ async def auth_middleware(request, call_next): # Auth disabled, allow all requests return await call_next(request) except Exception: - # If we can't check auth status, allow request (fail open for DB issues) - return await call_next(request) + logging.getLogger(__name__).exception("auth_middleware: failing closed on auth-probe error from %s", path) + return JSONResponse( + status_code=503, + content={"detail": "Authentication service temporarily unavailable"}, + ) # Auth is enabled - require valid token auth_header = request.headers.get("Authorization") diff --git a/backend/tests/integration/test_security.py b/backend/tests/integration/test_security.py index fae82caf2..cba87af71 100644 --- a/backend/tests/integration/test_security.py +++ b/backend/tests/integration/test_security.py @@ -1594,8 +1594,19 @@ class TestEncryptionStatusEndpoint: @pytest.mark.asyncio @pytest.mark.integration - async def test_status_returns_500_on_db_error(self, async_client, monkeypatch): - """A8: SQLAlchemyError during count queries → 500 with static message.""" + async def test_status_returns_503_on_db_error(self, async_client, monkeypatch): + """A8: a DB failure during the request must NOT leak internal detail + and must NOT silently succeed. + + Post-GHSA-6mf4-q26m-47pv: the auth middleware's ``is_auth_enabled`` + probe runs its own DB query before the route is dispatched. Patching + ``AsyncSession.execute`` to raise now trips the middleware first and + the request fails closed with 503 — a stronger guarantee than the + previous route-level 500, because under the old fail-open the + middleware would have proceeded to dispatch the route unauthenticated. + Either status would be acceptable; the assertion here pins the + defense-in-depth posture (request denied, no leak). + """ from unittest.mock import AsyncMock from sqlalchemy.exc import SQLAlchemyError @@ -1608,8 +1619,11 @@ class TestEncryptionStatusEndpoint: monkeypatch.setattr("sqlalchemy.ext.asyncio.AsyncSession.execute", AsyncMock(side_effect=_raise)) resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) - assert resp.status_code == 500 - assert "encryption status" in resp.json().get("detail", "").lower() + assert resp.status_code in (500, 503) + # Either layer's error message must not leak the SQLAlchemy details. + body = resp.json().get("detail", "").lower() + assert "simulated" not in body + assert "sqlalchemy" not in body @pytest.mark.asyncio @pytest.mark.integration diff --git a/backend/tests/unit/test_auth_fail_closed.py b/backend/tests/unit/test_auth_fail_closed.py new file mode 100644 index 000000000..fa25fe74c --- /dev/null +++ b/backend/tests/unit/test_auth_fail_closed.py @@ -0,0 +1,81 @@ +"""Regression tests for the GHSA-6mf4-q26m-47pv fail-open auth bypass. + +The previous version of ``is_auth_enabled`` caught every exception and +returned False (auth disabled). An attacker could trigger a DB-side +exception — the documented PoC exhausts file descriptors via a flood on +``/api/v1/auth/login`` until the next SQLite ``connect`` raises — and then +hit any protected endpoint during that fail-open window with no token at +all. Severity CVSS 9.8. + +These tests pin the fail-closed contract: + +1. ``is_auth_enabled`` propagates any DB exception (instead of swallowing + it and returning False). +2. The "no settings row" path still returns False (auth was legitimately + never configured). +3. ``setting.value == "true"`` still returns True. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from backend.app.core.auth import is_auth_enabled + + +@pytest.mark.asyncio +async def test_is_auth_enabled_propagates_db_exception_instead_of_failing_open(): + """The core regression for GHSA-6mf4-q26m-47pv. A DB error during the + auth-enabled probe must propagate — fail closed — instead of returning + False and treating the system as auth-disabled.""" + + db = AsyncMock() + db.execute = AsyncMock(side_effect=OSError("simulated file-descriptor exhaustion")) + + with pytest.raises(OSError, match="simulated file-descriptor exhaustion"): + await is_auth_enabled(db) + + +@pytest.mark.asyncio +async def test_is_auth_enabled_returns_false_when_settings_row_absent(): + """Legitimate 'auth was never configured' path: the settings row simply + does not exist. ``scalar_one_or_none`` returns None, no exception, and + the function returns False — system is auth-disabled by configuration, + not because the DB blew up.""" + + result = MagicMock() + result.scalar_one_or_none = MagicMock(return_value=None) + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + assert await is_auth_enabled(db) is False + + +@pytest.mark.asyncio +async def test_is_auth_enabled_returns_true_when_setting_value_is_true(): + """Happy path: the settings row exists and its value is "true" → auth + is enabled and the caller must require credentials.""" + + setting = MagicMock() + setting.value = "true" + result = MagicMock() + result.scalar_one_or_none = MagicMock(return_value=setting) + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + assert await is_auth_enabled(db) is True + + +@pytest.mark.asyncio +async def test_is_auth_enabled_returns_false_when_setting_value_is_false(): + """Happy path: the settings row exists and its value is "false" → auth + is disabled by configuration (legitimate, not exception).""" + + setting = MagicMock() + setting.value = "false" + result = MagicMock() + result.scalar_one_or_none = MagicMock(return_value=setting) + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + assert await is_auth_enabled(db) is False From b6fa77a64980acdb5fee7015c441cde4254feada Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 30 May 2026 13:53:42 +0200 Subject: [PATCH 30/37] Bumped version --- backend/app/core/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ae5e49110..5deb530ad 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,7 +6,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.2.4.3" +APP_VERSION = "0.2.4.4" GITHUB_REPO = "maziggy/bambuddy" BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report") From 0967f9758bf806131a3fcde54aa054c9b31e0ad7 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 30 May 2026 14:18:45 +0200 Subject: [PATCH 31/37] test(pytest): silence upstream starlette httpx2 deprecation noise --- backend/tests/pytest.ini | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/tests/pytest.ini b/backend/tests/pytest.ini index a70c01e48..5f8f7d385 100644 --- a/backend/tests/pytest.ini +++ b/backend/tests/pytest.ini @@ -8,6 +8,12 @@ filterwarnings = # Filter warnings from async mocks - coroutines created by mocks that are # intentionally not awaited (expected behavior in unit tests) ignore:coroutine.*was never awaited:RuntimeWarning + # Starlette ≥ 1.1 fires its own StarletteDeprecationWarning at import + # time of starlette.testclient asking us to migrate to ``httpx2``. + # Upstream-internal migration; nothing actionable in Bambuddy and a + # full move to httpx2 is its own change. Match by message text so the + # filter holds regardless of which warning class starlette uses. + ignore:Using .httpx. with .starlette.testclient. is deprecated markers = unit: Unit tests (fast, no external deps) integration: Integration tests (slower, test full API) From 7fa58d86896f2d2ec514818b61af254a74928a59 Mon Sep 17 00:00:00 2001 From: MartinNYHC Date: Mon, 1 Jun 2026 15:33:27 +0200 Subject: [PATCH 32/37] Housekeeping --- SECURITY.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 2b51b88ce..e1658ceca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,17 +6,12 @@ The Bambuddy team takes security seriously. We appreciate your efforts to respon ### How to Report -**Please DO NOT report security vulnerabilities through public GitHub issues.** +**Please DO NOT report security vulnerabilities through public GitHub.** Instead, please report them via email to: **security@bambuddy.cool** -Or use GitHub's private vulnerability reporting feature: -1. Go to the [Security tab](https://github.com/maziggy/bambuddy/security) -2. Click "Report a vulnerability" -3. Fill out the form with details - ### What to Include Please include the following information in your report: From 4582a28866fbc19edc1d43f2fbfbd8ab08e97079 Mon Sep 17 00:00:00 2001 From: "Marko@VMHOMELAB" <122116735+vmhomelab@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:17:25 +0200 Subject: [PATCH 33/37] Feature/windows installer (#1529) Add feature: Added a powershells script to easily install bambuddy on windows --- README.md | 10 + install/README.md | 39 ++ install/windows-installer.ps1 | 1100 +++++++++++++++++++++++++++++++++ 3 files changed, 1149 insertions(+) create mode 100644 install/windows-installer.ps1 diff --git a/README.md b/README.md index 1794ea169..469d3871b 100644 --- a/README.md +++ b/README.md @@ -627,6 +627,16 @@ Open **http://localhost:8000** and add your printer! > **Need detailed instructions?** See the [Installation Guide](http://wiki.bambuddy.cool/getting-started/installation/) +### Windows Native Installation + +Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/maziggy/bambuddy/main/install/windows-installer.ps1 -OutFile windows-installer.ps1; .\windows-installer.ps1" + +``` +> Installs Bambuddy natively on Windows using Git, Python, a virtual environment, separate data/log directories, and optional NSSM Windows Service registration. + ### Enabling Developer Mode Developer Mode allows third-party software like Bambuddy to control your printer over the local network. diff --git a/install/README.md b/install/README.md index d7aa097d0..6b1420009 100644 --- a/install/README.md +++ b/install/README.md @@ -25,6 +25,18 @@ powershell -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercon curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh -o install.sh && chmod +x install.sh && ./install.sh ``` +### Windows Native Installation + +**Windows PowerShell:** + +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/maziggy/bambuddy/main/install/windows-installer.ps1 -OutFile windows-installer.ps1; .\windows-installer.ps1" +``` + +**Unattended:** +```powershell +.\windows-installer.ps1 -InstallDir C:\Bambuddy -Port 8000 -Yes +``` --- ## Scripts Overview @@ -34,6 +46,7 @@ curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/insta | `install.sh` | Linux, macOS | Native (Python venv) | | `docker-install.sh` | Linux, macOS | Docker | | `docker-install.ps1` | Windows (Docker Desktop) | Docker | +| `windows-installer.ps1` | Windows (Native) | Windows Service | | `update.sh` | Linux (systemd) | Native update helper | --- @@ -78,6 +91,32 @@ Installs BamBuddy with Python virtual environment and optional systemd/launchd s # Skip service setup ./install.sh --no-service -y ``` +### `windows-installer.ps1` (Windows) + +Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/maziggy/bambuddy/main/install/windows-installer.ps1 -OutFile windows-installer.ps1; .\windows-installer.ps1" + +``` +> Installs Bambuddy natively on Windows using Git, Python, a virtual environment, and optional NSSM Windows Service registration. + +**Parameters:** +```powershell +-InstallDir PATH Installation directory (default: C:\Bambuddy) +-Port PORT Port to listen on (default: 8000) +-Yes Non-interactive mode, accept defaults +-Silent Non-interactive mode with reduced console output +-NoService Skip Windows Service setup +-NoStart Do not start Bambuddy at the end +-LocalOnly Bind to 127.0.0.1 instead of all LAN interfaces +``` + +The installer stores the Git checkout in `INSTALL_DIR\bambuddy`, user data in +`INSTALL_DIR\data`, and application logs in `INSTALL_DIR\logs` so updates and +re-clones do not delete runtime data. If an earlier Windows installer run left +runtime data in the Git checkout, the installer moves known data and log paths +to the new locations before starting Bambuddy. --- diff --git a/install/windows-installer.ps1 b/install/windows-installer.ps1 new file mode 100644 index 000000000..f1f1cd4dd --- /dev/null +++ b/install/windows-installer.ps1 @@ -0,0 +1,1100 @@ +#requires -version 5.1 + +<# +.SYNOPSIS + Bambuddy Windows Installer + +.DESCRIPTION + - Uses default install directory C:\Bambuddy + - Lets user choose custom install directory + - Checks and installs Git if missing + - Checks and installs Python 3 if missing + - Fixes permissions on install directory + - Clones Bambuddy repository + - Stores user data and application logs outside the Git checkout + - Creates Python venv + - Installs requirements + - Lets user choose port, default 8000 + - Creates installer log + - Creates runtime log + - Optionally creates Windows Firewall rule + - Creates Start-Bambuddy.ps1 + - Optionally registers Bambuddy as Windows Service using NSSM + - Optionally starts Bambuddy + +.PARAMETER Yes + Runs unattended and accepts defaults for prompts. + +.PARAMETER Silent + Runs unattended with reduced console output. +#> + +[CmdletBinding()] +param ( + [ValidateNotNullOrEmpty()] + [string]$InstallDir = "C:\Bambuddy", + + [ValidateRange(1, 65535)] + [int]$Port = 8000, + + [switch]$Yes, + [switch]$Silent, + [switch]$NoService, + [switch]$NoStart, + [switch]$LocalOnly +) + +$ErrorActionPreference = "Stop" + +$script:LogFile = $null +$script:Yes = [bool]$Yes +$script:Silent = [bool]$Silent + +# ------------------------------------------------------------ +# Helper functions +# ------------------------------------------------------------ + +function Show-BambuddyBanner { + Write-Host "" + Write-Host "============================================================" -ForegroundColor Green + Write-Host " ____ _ _ _ " -ForegroundColor Green + Write-Host " | _ \ | | | | | | " -ForegroundColor Green + Write-Host " | |_) | __ _ _ __ ___ | |__ _ _ __| | __| |_ _ " -ForegroundColor Green + Write-Host " | _ < / _` | '_ ` _ \| '_ \| | | |/ _` |/ _` | | | | " -ForegroundColor Green + Write-Host " | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | " -ForegroundColor Green + Write-Host " |____/ \__,_|_| |_| |_|_.__/ \__,_|\__,_|\__,_|\__, | " -ForegroundColor Green + Write-Host " __/ | " -ForegroundColor Green + Write-Host " |___/ " -ForegroundColor Green + Write-Host "============================================================" -ForegroundColor Green + Write-Host " Bambuddy Setup - Install & Upgrade" -ForegroundColor White + Write-Host "============================================================" -ForegroundColor Green + Write-Host "" +} +function Write-Log { + param ( + [Parameter(Mandatory = $true)] + [string]$Message, + + [string]$Level = "INFO", + + [ConsoleColor]$Color = [ConsoleColor]::White + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $line = "[$timestamp] [$Level] $Message" + + if (-not $script:Silent) { + Write-Host $Message -ForegroundColor $Color + } + + if ($script:LogFile) { + try { + $line | Out-File -FilePath $script:LogFile -Append -Encoding UTF8 + } + catch { + Write-Host "Could not write to log file: $($_.Exception.Message)" -ForegroundColor Yellow + } + } +} + +function Start-InstallerLogging { + param ( + [Parameter(Mandatory = $true)] + [string]$InstallDir + ) + + $script:LogFile = Join-Path $InstallDir "install.log" + + if (-not (Test-Path $InstallDir)) { + New-Item -Path $InstallDir -ItemType Directory -Force | Out-Null + } + + try { + "" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8 + "============================================================" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8 + "Bambuddy Installer started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8 + "============================================================" | Out-File -FilePath $script:LogFile -Append -Encoding UTF8 + + Write-Log "Logging enabled: $script:LogFile" "INFO" Green + } + catch { + Write-Host "Could not initialize installer log: $($_.Exception.Message)" -ForegroundColor Yellow + $script:LogFile = $null + } +} + +function Test-IsAdmin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +} + +function Restart-AsAdmin { + Write-Host "Script is not running as Administrator. Relaunching elevated..." -ForegroundColor Yellow + + try { + $arguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$PSCommandPath`"") + + if ($InstallDir -ne "C:\Bambuddy") { + $arguments += @("-InstallDir", "`"$InstallDir`"") + } + + if ($Port -ne 8000) { + $arguments += @("-Port", $Port) + } + + if ($Yes) { $arguments += "-Yes" } + if ($Silent) { $arguments += "-Silent" } + if ($NoService) { $arguments += "-NoService" } + if ($NoStart) { $arguments += "-NoStart" } + if ($LocalOnly) { $arguments += "-LocalOnly" } + + Start-Process powershell.exe ` + -ArgumentList $arguments ` + -Verb RunAs + + exit + } + catch { + Write-Host "Elevation cancelled or failed. Please run PowerShell as Administrator." -ForegroundColor Red + if (-not $script:Silent) { + Read-Host "Press Enter to close" + } + exit 1 + } +} + +function Test-CommandExists { + param ( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + return [bool](Get-Command $Command -ErrorAction SilentlyContinue) +} + +function Update-EnvironmentPath { + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = "$machinePath;$userPath" +} +function Install-WithWinget { + param ( + [Parameter(Mandatory = $true)] + [string]$PackageId, + + [Parameter(Mandatory = $true)] + [string]$DisplayName + ) + + if (-not (Test-CommandExists "winget")) { + throw "winget is not available. Please install $DisplayName manually and run this script again." + } + + Write-Log "Installing $DisplayName via winget..." "INFO" Cyan + + & winget install ` + --id $PackageId ` + --exact ` + --silent ` + --accept-package-agreements ` + --accept-source-agreements + + if ($LASTEXITCODE -ne 0) { + throw "Failed to install $DisplayName via winget." + } + + Update-EnvironmentPath +} + +function Read-YesNo { + param ( + [Parameter(Mandatory = $true)] + [string]$Question, + + [bool]$DefaultYes = $true + ) + + # Keep installer prompts English-only until the installer has full locale support. + if ($script:Yes -or $script:Silent) { + return $DefaultYes + } + + if ($DefaultYes) { + $suffix = "[Y/n]" + } + else { + $suffix = "[y/N]" + } + + while ($true) { + $answer = Read-Host "$Question $suffix" + + if ([string]::IsNullOrWhiteSpace($answer)) { + return $DefaultYes + } + + switch ($answer.ToLower()) { + "y" { return $true } + "yes" { return $true } + "n" { return $false } + "no" { return $false } + default { + Write-Host "Please answer yes or no." -ForegroundColor Yellow + } + } + } +} + +function Read-Port { + param ( + [int]$DefaultPort = 8000 + ) + + if ($script:Yes -or $script:Silent) { + return $DefaultPort + } + + while ($true) { + $inputPort = Read-Host "Enter Bambuddy port or press Enter for default [$DefaultPort]" + + if ([string]::IsNullOrWhiteSpace($inputPort)) { + return $DefaultPort + } + + $parsedPort = 0 + + if ([int]::TryParse($inputPort, [ref]$parsedPort)) { + if ($parsedPort -ge 1 -and $parsedPort -le 65535) { + return $parsedPort + } + } + + Write-Host "Invalid port. Please enter a number between 1 and 65535." -ForegroundColor Yellow + } +} + +function Test-WriteAccess { + param ( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + if (-not (Test-Path $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } + + $testFile = Join-Path $Path "write-test.tmp" + + "test" | Set-Content -Path $testFile -Force + Remove-Item $testFile -Force + + return $true + } + catch { + return $false + } +} + +function Set-FolderPermissions { + param ( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + Write-Log "Fixing permissions for: $Path" "INFO" Cyan + + if (-not (Test-Path $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } + + $currentUser = "$env:USERDOMAIN\$env:USERNAME" + + try { + # Enable inheritance + & icacls $Path /inheritance:e | Out-Null + + # Grant local Administrators full access by SID, language independent + & icacls $Path /grant "*S-1-5-32-544:(OI)(CI)F" /T /C | Out-Null + + # Grant current user full access + & icacls $Path /grant "$($currentUser):(OI)(CI)F" /T /C | Out-Null + } + catch { + Write-Log "Permission adjustment failed or partially failed. Continuing with write test..." "WARN" Yellow + } + + if (-not (Test-WriteAccess -Path $Path)) { + throw "No write permission to '$Path'. Try another path, for example C:\Temp\Bambuddy, or check Windows Defender Controlled Folder Access." + } + + Write-Log "Write access confirmed." "INFO" Green +} + +function Get-PythonCommand { + if (Test-CommandExists "python") { + if (Test-PythonVersion -PythonCommand "python") { + return "python" + } + } + + if (Test-CommandExists "py") { + if (Test-PythonVersion -PythonCommand "py") { + return "py" + } + } + + return $null +} + +function Test-PythonVersion { + param ( + [Parameter(Mandatory = $true)] + [string]$PythonCommand + ) + + try { + if ($PythonCommand -eq "py") { + $versionOutput = & py -3 --version 2>&1 + } + else { + $versionOutput = & python --version 2>&1 + } + + if ($versionOutput -match "Python\s+(\d+)\.(\d+)\.(\d+)") { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + + if ($major -gt 3 -or ($major -eq 3 -and $minor -ge 10)) { + return $true + } + + Write-Log "Found $versionOutput, but Bambuddy requires Python 3.10 or newer." "WARN" Yellow + } + } + catch {} + + return $false +} + +function Test-PortAvailable { + param ( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + try { + $listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue + return -not $listener + } + catch { + Write-Log "Could not check whether TCP port $Port is already in use. Continuing." "WARN" Yellow + return $true + } +} + +function Move-LegacyRuntimeData { + param ( + [Parameter(Mandatory = $true)] + [string]$BambuddyDir, + + [Parameter(Mandatory = $true)] + [string]$DataDir, + + [Parameter(Mandatory = $true)] + [string]$LogDir + ) + + $legacyMappings = @( + @{ Source = (Join-Path $BambuddyDir "bambuddy.db"); Destination = (Join-Path $DataDir "bambuddy.db") }, + @{ Source = (Join-Path $BambuddyDir "archive"); Destination = (Join-Path $DataDir "archive") }, + # external_links.py resolves user-uploaded icons to base_dir/icons; after + # DATA_DIR migration that becomes $DataDir/icons, so move any legacy ones. + @{ Source = (Join-Path $BambuddyDir "icons"); Destination = (Join-Path $DataDir "icons") } + ) + + foreach ($mapping in $legacyMappings) { + if ((Test-Path $mapping.Source) -and (-not (Test-Path $mapping.Destination))) { + Write-Log "Moving legacy runtime data from '$($mapping.Source)' to '$($mapping.Destination)'." "INFO" Cyan + Move-Item -Path $mapping.Source -Destination $mapping.Destination -Force + } + elseif ((Test-Path $mapping.Source) -and (Test-Path $mapping.Destination)) { + Write-Log "Legacy runtime data remains at '$($mapping.Source)' because '$($mapping.Destination)' already exists." "WARN" Yellow + } + } + + $legacyDataRoot = Join-Path $BambuddyDir "data" + if (Test-Path $legacyDataRoot) { + $legacyDataItems = Get-ChildItem -Path $legacyDataRoot -Force -ErrorAction SilentlyContinue + + foreach ($legacyDataItem in $legacyDataItems) { + $destination = Join-Path $DataDir $legacyDataItem.Name + + if (-not (Test-Path $destination)) { + Write-Log "Moving legacy runtime data from '$($legacyDataItem.FullName)' to '$destination'." "INFO" Cyan + Move-Item -Path $legacyDataItem.FullName -Destination $destination -Force + } + else { + Write-Log "Legacy runtime data remains at '$($legacyDataItem.FullName)' because '$destination' already exists." "WARN" Yellow + } + } + + if (-not (Get-ChildItem -Path $legacyDataRoot -Force -ErrorAction SilentlyContinue)) { + Remove-Item $legacyDataRoot -Force + } + } + + $legacyLogDir = Join-Path $BambuddyDir "logs" + if (Test-Path $legacyLogDir) { + $legacyLogs = Get-ChildItem -Path $legacyLogDir -Force -ErrorAction SilentlyContinue + + foreach ($legacyLog in $legacyLogs) { + $destination = Join-Path $LogDir $legacyLog.Name + + if (-not (Test-Path $destination)) { + Write-Log "Moving legacy log '$($legacyLog.FullName)' to '$destination'." "INFO" Cyan + Move-Item -Path $legacyLog.FullName -Destination $destination -Force + } + } + } +} + +function Invoke-Python { + param ( + [Parameter(Mandatory = $true)] + [string]$PythonCommand, + + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + if ($PythonCommand -eq "py") { + & py -3 @Arguments + } + else { + & python @Arguments + } + + return $LASTEXITCODE +} + +function Install-NSSM { + param ( + [Parameter(Mandatory = $true)] + [string]$InstallDir + ) + + $nssmDir = Join-Path $InstallDir "nssm" + $nssmExe = Join-Path $nssmDir "nssm.exe" + + if (Test-Path $nssmExe) { + Write-Log "NSSM already exists: $nssmExe" "INFO" Green + return $nssmExe + } + + Write-Log "Installing NSSM..." "INFO" Cyan + + $nssmZip = Join-Path $InstallDir "nssm.zip" + $nssmExtract = Join-Path $InstallDir "nssm_extract" + + if (Test-Path $nssmExtract) { + Remove-Item $nssmExtract -Recurse -Force + } + + New-Item -Path $nssmDir -ItemType Directory -Force | Out-Null + + $nssmUrl = "https://nssm.cc/release/nssm-2.24.zip" + $expectedNssmSha256 = "727D1E42275C605E0F04ABA98095C38A8E1E46DEF453CDFFCE42869428AA6743" + + Write-Log "Downloading NSSM from $nssmUrl" "INFO" Cyan + + Invoke-WebRequest -Uri $nssmUrl -OutFile $nssmZip -UseBasicParsing + + $actualNssmSha256 = (Get-FileHash -Path $nssmZip -Algorithm SHA256).Hash + if ($actualNssmSha256 -ne $expectedNssmSha256) { + Remove-Item $nssmZip -Force -ErrorAction SilentlyContinue + throw "NSSM download checksum mismatch. Expected $expectedNssmSha256 but got $actualNssmSha256." + } + + Write-Log "NSSM checksum verified." "INFO" Green + + Expand-Archive -Path $nssmZip -DestinationPath $nssmExtract -Force + + $possibleNssmExe = Get-ChildItem -Path $nssmExtract -Recurse -Filter "nssm.exe" | + Where-Object { $_.FullName -match "\\win64\\" } | + Select-Object -First 1 + + if (-not $possibleNssmExe) { + throw "Could not find NSSM win64 executable after extraction." + } + + Copy-Item -Path $possibleNssmExe.FullName -Destination $nssmExe -Force + + Remove-Item $nssmZip -Force -ErrorAction SilentlyContinue + Remove-Item $nssmExtract -Recurse -Force -ErrorAction SilentlyContinue + + if (-not (Test-Path $nssmExe)) { + throw "NSSM installation failed. nssm.exe was not found." + } + + Write-Log "NSSM installed: $nssmExe" "INFO" Green + + return $nssmExe +} + +function Register-BambuddyService { + param ( + [Parameter(Mandatory = $true)] + [string]$ServiceName, + + [Parameter(Mandatory = $true)] + [string]$StartScriptPath, + + [Parameter(Mandatory = $true)] + [string]$InstallDir, + + [Parameter(Mandatory = $true)] + [string]$BambuddyDir, + + [Parameter(Mandatory = $true)] + [string]$RuntimeLogPath, + + [Parameter(Mandatory = $true)] + [string]$RuntimeErrorLogPath, + + [Parameter(Mandatory = $true)] + [string]$DataDir, + + [Parameter(Mandatory = $true)] + [string]$LogDir + ) + + Write-Log "Preparing Windows Service registration using NSSM..." "INFO" Cyan + + $nssmExe = Install-NSSM -InstallDir $InstallDir + + $existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + + if ($existingService) { + Write-Log "Service '$ServiceName' already exists." "WARN" Yellow + + $replaceService = Read-YesNo -Question "Do you want to replace the existing service '$ServiceName'?" -DefaultYes $true + + if ($replaceService) { + Write-Log "Stopping existing service if running..." "INFO" Cyan + + try { + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + catch {} + + Write-Log "Removing existing service..." "INFO" Cyan + + & $nssmExe remove $ServiceName confirm | Out-Null + + Start-Sleep -Seconds 2 + } + else { + Write-Log "Keeping existing service. Service registration skipped." "WARN" Yellow + return + } + } + + $powerShellExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + $serviceArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$StartScriptPath`"" + + Write-Log "Creating NSSM service '$ServiceName'..." "INFO" Cyan + + & $nssmExe install $ServiceName $powerShellExe $serviceArguments + + if ($LASTEXITCODE -ne 0) { + throw "NSSM failed to create service '$ServiceName'." + } + + $configuredArguments = & $nssmExe get $ServiceName AppParameters + Write-Log "NSSM AppParameters: $configuredArguments" "INFO" Cyan + if ($configuredArguments -ne $serviceArguments) { + Write-Log "NSSM AppParameters differ from expected value. Verify paths with spaces before starting the service." "WARN" Yellow + } + + & $nssmExe set $ServiceName DisplayName "Bambuddy" + & $nssmExe set $ServiceName Description "Bambuddy backend service" + & $nssmExe set $ServiceName AppDirectory $BambuddyDir + & $nssmExe set $ServiceName Start SERVICE_AUTO_START + & $nssmExe set $ServiceName AppEnvironmentExtra "+DATA_DIR=$DataDir" "+LOG_DIR=$LogDir" + + # Logging + & $nssmExe set $ServiceName AppStdout $RuntimeLogPath + & $nssmExe set $ServiceName AppStderr $RuntimeErrorLogPath + & $nssmExe set $ServiceName AppRotateFiles 1 + & $nssmExe set $ServiceName AppRotateOnline 1 + & $nssmExe set $ServiceName AppRotateSeconds 86400 + & $nssmExe set $ServiceName AppRotateBytes 10485760 + + # Restart behavior + & $nssmExe set $ServiceName AppExit Default Restart + & $nssmExe set $ServiceName AppExit 0 Exit + & $nssmExe set $ServiceName AppRestartDelay 5000 + + Write-Log "Service '$ServiceName' created successfully with NSSM." "INFO" Green + + $startServiceNow = Read-YesNo -Question "Start Windows Service '$ServiceName' now?" -DefaultYes $true + + if ($startServiceNow) { + Write-Log "Starting service '$ServiceName'..." "INFO" Cyan + + Start-Service -Name $ServiceName + + Start-Sleep -Seconds 5 + + $service = Get-Service -Name $ServiceName + + Write-Log "Service state: $($service.Status)" "INFO" Green + + if ($service.Status -ne "Running") { + Write-Log "Service did not stay running. Check runtime log: $RuntimeLogPath" "WARN" Yellow + } + } +} + +# ------------------------------------------------------------ +# Main +# ------------------------------------------------------------ + +try { + if (-not (Test-IsAdmin)) { + Restart-AsAdmin + } + + if (-not $script:Silent) { + Show-BambuddyBanner + } + + # ------------------------------------------------------------ + # Install directory + # ------------------------------------------------------------ + + $defaultInstallDir = $InstallDir + + if ($PSBoundParameters.ContainsKey("InstallDir")) { + $installDir = $InstallDir.Trim('"') + } + else { + $useDefaultDir = Read-YesNo -Question "Use default install directory '$defaultInstallDir'?" -DefaultYes $true + + if ($useDefaultDir) { + $installDir = $defaultInstallDir + } + else { + while ($true) { + $customDir = Read-Host "Enter custom install directory" + + if (-not [string]::IsNullOrWhiteSpace($customDir)) { + $installDir = $customDir.Trim('"') + break + } + + Write-Host "Install directory cannot be empty." -ForegroundColor Yellow + } + } + } + + if (-not (Test-Path $installDir)) { + New-Item -Path $installDir -ItemType Directory -Force | Out-Null + } + + Start-InstallerLogging -InstallDir $installDir + + Write-Log "Install directory: $installDir" "INFO" Cyan + + Set-FolderPermissions -Path $installDir + + # ------------------------------------------------------------ + # Port selection + # ------------------------------------------------------------ + + $port = Read-Port -DefaultPort $Port + + if (-not (Test-PortAvailable -Port $port)) { + throw "TCP port $port is already in use. Choose another port with -Port or stop the conflicting service." + } + + Write-Log "Selected port: $port" "INFO" Cyan + + $exposeOnLan = -not $LocalOnly + if (-not $LocalOnly) { + $lanQuestion = "Expose Bambuddy on the LAN? Choose No to bind only to this computer. Exposing Bambuddy on the LAN binds to all network interfaces. Bambuddy is unauthenticated by default. After installation, open Settings -> Security and enable auth before relying on LAN access." + $exposeOnLan = Read-YesNo -Question $lanQuestion -DefaultYes $true + } + + if ($exposeOnLan) { + $bindAddress = "0.0.0.0" + } + else { + $bindAddress = "127.0.0.1" + } + + Write-Log "Bind address: $bindAddress" "INFO" Cyan + + # ------------------------------------------------------------ + # Git check / install + # ------------------------------------------------------------ + + Write-Log "Checking Git..." "INFO" Cyan + + if (-not (Test-CommandExists "git")) { + Write-Log "Git is not installed." "WARN" Yellow + Install-WithWinget -PackageId "Git.Git" -DisplayName "Git" + Update-EnvironmentPath + } + + if (-not (Test-CommandExists "git")) { + throw "Git was installed, but is still not available in PATH. Restart PowerShell and run this script again." + } + + $gitVersion = & git --version + Write-Log "Git found: $gitVersion" "INFO" Green + + # ------------------------------------------------------------ + # Python check / install + # ------------------------------------------------------------ + + Write-Log "Checking Python..." "INFO" Cyan + + $pythonCommand = Get-PythonCommand + + if (-not $pythonCommand) { + Write-Log "Python 3 is not installed." "WARN" Yellow + Install-WithWinget -PackageId "Python.Python.3.12" -DisplayName "Python 3" + Update-EnvironmentPath + $pythonCommand = Get-PythonCommand + } + + if (-not $pythonCommand) { + throw "Python 3.10 or newer was not found in PATH. Restart PowerShell after installation or install Python 3.10+ manually." + } + + Write-Log "Python command: $pythonCommand" "INFO" Green + + # ------------------------------------------------------------ + # Clone or update Bambuddy repository + # ------------------------------------------------------------ + + Write-Log "Preparing Bambuddy repository..." "INFO" Cyan + + $bambuddyRepoUrl = "https://github.com/maziggy/bambuddy.git" + $bambuddyFolderName = "bambuddy" + $bambuddyDir = Join-Path $installDir $bambuddyFolderName + $dataDir = Join-Path $installDir "data" + $appLogDir = Join-Path $installDir "logs" + + Write-Log "Repository target: $bambuddyDir" "INFO" Cyan + + New-Item -Path $dataDir -ItemType Directory -Force | Out-Null + New-Item -Path $appLogDir -ItemType Directory -Force | Out-Null + + if (Test-Path $bambuddyDir) { + $gitDir = Join-Path $bambuddyDir ".git" + + if (Test-Path $gitDir) { + Write-Log "Existing Bambuddy Git repository found." "WARN" Yellow + + $updateExisting = Read-YesNo -Question "Do you want to update the existing repository with git pull?" -DefaultYes $true + + if ($updateExisting) { + Push-Location $bambuddyDir + + Write-Log "Running git pull..." "INFO" Cyan + & git pull + + $gitPullExitCode = $LASTEXITCODE + Pop-Location + + if ($gitPullExitCode -ne 0) { + throw "git pull failed." + } + } + } + else { + Write-Log "Target directory exists but is not a valid Git repository: $bambuddyDir" "WARN" Yellow + + $removeBroken = Read-YesNo -Question "Remove this directory and clone again? This deletes '$bambuddyDir'." -DefaultYes $false + + if ($removeBroken) { + Move-LegacyRuntimeData -BambuddyDir $bambuddyDir -DataDir $dataDir -LogDir $appLogDir + Write-Log "Removing existing target directory..." "INFO" Cyan + Remove-Item $bambuddyDir -Recurse -Force + } + else { + throw "Cannot continue because '$bambuddyDir' already exists and is not a Git repository." + } + } + } + + if (-not (Test-Path $bambuddyDir)) { + Write-Log "Testing folder creation before git clone..." "INFO" Cyan + + $testCloneDir = Join-Path $installDir "git-write-test" + + if (Test-Path $testCloneDir) { + Remove-Item $testCloneDir -Recurse -Force + } + + New-Item -Path $testCloneDir -ItemType Directory -Force | Out-Null + "test" | Set-Content -Path (Join-Path $testCloneDir "test.txt") -Force + Remove-Item $testCloneDir -Recurse -Force + + Write-Log "Folder creation test successful." "INFO" Green + + Write-Log "Cloning Bambuddy repository..." "INFO" Cyan + + Push-Location $installDir + + & git clone --depth=1 --progress $bambuddyRepoUrl $bambuddyFolderName + + $gitCloneExitCode = $LASTEXITCODE + + Pop-Location + + if ($gitCloneExitCode -ne 0) { + throw "Failed to clone Bambuddy repository to '$bambuddyDir'." + } + } + + if (-not (Test-Path $bambuddyDir)) { + throw "Bambuddy directory was not created: $bambuddyDir" + } + + Move-LegacyRuntimeData -BambuddyDir $bambuddyDir -DataDir $dataDir -LogDir $appLogDir + + # ------------------------------------------------------------ + # Python virtual environment + # ------------------------------------------------------------ + + Write-Log "Setting up Python virtual environment..." "INFO" Cyan + + Push-Location $bambuddyDir + + $venvDir = Join-Path $bambuddyDir "venv" + $venvPython = Join-Path $venvDir "Scripts\python.exe" + $venvPip = Join-Path $venvDir "Scripts\pip.exe" + + if (-not (Test-Path $venvPython)) { + Write-Log "Creating virtual environment..." "INFO" Cyan + + $venvExitCode = Invoke-Python -PythonCommand $pythonCommand -Arguments @("-m", "venv", "venv") + + if ($venvExitCode -ne 0) { + Pop-Location + throw "Failed to create Python virtual environment." + } + } + else { + Write-Log "Virtual environment already exists." "INFO" Green + } + + if (-not (Test-Path $venvPython)) { + Pop-Location + throw "Virtual environment Python executable was not found: $venvPython" + } + + # ------------------------------------------------------------ + # Install requirements + # ------------------------------------------------------------ + + Write-Log "Installing Python dependencies..." "INFO" Cyan + + $requirementsFile = Join-Path $bambuddyDir "requirements.txt" + + if (-not (Test-Path $requirementsFile)) { + Pop-Location + throw "requirements.txt was not found in $bambuddyDir" + } + + Write-Log "Upgrading pip..." "INFO" Cyan + & $venvPython -m pip install --upgrade pip + + if ($LASTEXITCODE -ne 0) { + Pop-Location + throw "Failed to upgrade pip." + } + + Write-Log "Installing requirements.txt..." "INFO" Cyan + & $venvPip install -r $requirementsFile + + if ($LASTEXITCODE -ne 0) { + Pop-Location + throw "Failed to install Python requirements." + } + + Pop-Location + + # ------------------------------------------------------------ + # Firewall rule + # ------------------------------------------------------------ + + $createFirewallRule = Read-YesNo -Question "Create Windows Firewall rule for TCP port $port?" -DefaultYes $true + + if ($createFirewallRule) { + $ruleName = "Bambuddy TCP $port" + + $existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue + + if (-not $existingRule) { + Write-Log "Creating firewall rule: $ruleName" "INFO" Cyan + + # Restrict to trusted profiles. Bambuddy ships with auth disabled by + # default; allowing Public would expose an unauthenticated UI on + # cafe / hotel / airport networks the moment Windows classifies them. + New-NetFirewallRule ` + -DisplayName $ruleName ` + -Direction Inbound ` + -Protocol TCP ` + -LocalPort $port ` + -Profile Domain,Private ` + -Action Allow | Out-Null + + Write-Log "Firewall rule created." "INFO" Green + } + else { + Write-Log "Firewall rule already exists: $ruleName" "WARN" Yellow + } + } + + # ------------------------------------------------------------ + # Create start script + # ------------------------------------------------------------ + + Write-Log "Creating start script..." "INFO" Cyan + + $startScriptPath = Join-Path $installDir "Start-Bambuddy.ps1" + $runtimeLogPath = Join-Path $installDir "bambuddy-runtime.log" + $runtimeErrorLogPath = Join-Path $installDir "bambuddy-runtime-error.log" + + $startScriptLines = @( + '$ErrorActionPreference = "Stop"', + '', + "`$BambuddyDir = `"$bambuddyDir`"", + "`$VenvPython = `"$venvPython`"", + "`$Port = $port", + "`$BindAddress = `"$bindAddress`"", + "`$env:DATA_DIR = `"$dataDir`"", + "`$env:LOG_DIR = `"$appLogDir`"", + '', + 'Set-Location "$BambuddyDir"', + '', + 'Write-Output "Starting Bambuddy on port $Port"', + 'Write-Output "Bind address: $BindAddress"', + 'Write-Output "Working directory: $BambuddyDir"', + 'Write-Output "Python executable: $VenvPython"', + 'Write-Output "Data directory: $env:DATA_DIR"', + 'Write-Output "Log directory: $env:LOG_DIR"', + '', + '& "$VenvPython" -m uvicorn backend.app.main:app --host $BindAddress --port $Port' + ) + + $startScriptContent = $startScriptLines -join [Environment]::NewLine + + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($startScriptPath, $startScriptContent, $utf8NoBom) + + Write-Log "Start script created: $startScriptPath" "INFO" Green + Write-Log "Runtime log path: $runtimeLogPath" "INFO" Green + Write-Log "Runtime error log path: $runtimeErrorLogPath" "INFO" Green + + # ------------------------------------------------------------ + # Optional Windows Service registration + # ------------------------------------------------------------ + + $registerService = (-not $NoService) -and (Read-YesNo -Question "Register Bambuddy as a Windows Service?" -DefaultYes $true) + + if ($registerService) { + Register-BambuddyService ` + -ServiceName "Bambuddy" ` + -StartScriptPath $startScriptPath ` + -InstallDir $installDir ` + -BambuddyDir $bambuddyDir ` + -RuntimeLogPath $runtimeLogPath ` + -RuntimeErrorLogPath $runtimeErrorLogPath ` + -DataDir $dataDir ` + -LogDir $appLogDir + } + + # ------------------------------------------------------------ + # Summary + # ------------------------------------------------------------ + + Write-Host "" + Write-Host "=== Installation completed ===" -ForegroundColor Green + Write-Host "Install directory: $installDir" + Write-Host "Repository path: $bambuddyDir" + Write-Host "Data directory: $dataDir" + Write-Host "App log directory: $appLogDir" + Write-Host "Port: $port" + Write-Host "Bind address: $bindAddress" + Write-Host "Installer log: $script:LogFile" + Write-Host "Service stdout: $runtimeLogPath" + Write-Host "Service stderr: $runtimeErrorLogPath" + Write-Host "Start script: $startScriptPath" + Write-Host "" + Write-Host "Manual start:" + Write-Host "powershell.exe -ExecutionPolicy Bypass -File `"$startScriptPath`"" + Write-Host "" + Write-Host "Service commands:" + Write-Host "Start-Service Bambuddy" + Write-Host "Stop-Service Bambuddy" + Write-Host "Restart-Service Bambuddy" + Write-Host "Get-Service Bambuddy" + Write-Host "" + + # ------------------------------------------------------------ + # Start manually if service was not registered + # ------------------------------------------------------------ + + if ((-not $registerService) -and (-not $NoStart) -and (-not $script:Yes) -and (-not $script:Silent)) { + $startNow = Read-YesNo -Question "Start Bambuddy now?" -DefaultYes $true + + if ($startNow) { + Write-Log "Starting Bambuddy manually..." "INFO" Green + Write-Host "Local URL: http://localhost:$port" + if ($bindAddress -eq "0.0.0.0") { + Write-Host "Network URL: http://:$port" + } + Write-Host "Press CTRL+C to stop Bambuddy." + Write-Host "" + + Set-Location $bambuddyDir + $env:DATA_DIR = $dataDir + $env:LOG_DIR = $appLogDir + & $venvPython -m uvicorn backend.app.main:app --host $bindAddress --port $port + } + } + + # When relaunched elevated via Restart-AsAdmin, the new PowerShell window + # closes the moment the script returns. Pause so the install summary is + # readable. Matches the catch-block gate below. + if ((Test-IsAdmin) -and (-not $script:Yes) -and (-not $script:Silent)) { + Write-Host "" + Read-Host "Press Enter to close" + } +} +catch { + Write-Host "" + Write-Host "ERROR:" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + + if ($script:LogFile) { + Add-Content -Path $script:LogFile -Value "[ERROR] $($_.Exception.Message)`n$($_.ScriptStackTrace)" -Encoding UTF8 + Write-Host "" + Write-Host "Installer log: $script:LogFile" -ForegroundColor Yellow + } + + if ((Test-IsAdmin) -and (-not $script:Yes) -and (-not $script:Silent)) { + Write-Host "" + Read-Host "Press Enter to close" + } + + exit 1 +} From 5dfd38597acae0a8a380bd3e7cbeb95d1384765a Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 2 Jun 2026 15:25:31 +0200 Subject: [PATCH 34/37] docs(install): polish Windows section + Service/Update/Troubleshooting entries PR #1529 added the Windows installer but the rendered README sections had a stray blank line inside the install one-liner's code fence and the cross-platform Service Management / Updating / Troubleshooting sections still only covered Linux + macOS + Docker. Fold in Windows entries (Start-Service, Get-NetTCPConnection, NSSM runtime log path) and link the README description to the Windows Installer wiki page so users know where the parameter reference and unattended examples live. --- README.md | 6 +++--- install/README.md | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 469d3871b..29fea1c35 100644 --- a/README.md +++ b/README.md @@ -629,13 +629,13 @@ Open **http://localhost:8000** and add your printer! ### Windows Native Installation -Windows PowerShell: +Windows PowerShell (run as Administrator — the installer self-elevates via UAC if not): ```powershell powershell -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/maziggy/bambuddy/main/install/windows-installer.ps1 -OutFile windows-installer.ps1; .\windows-installer.ps1" - ``` -> Installs Bambuddy natively on Windows using Git, Python, a virtual environment, separate data/log directories, and optional NSSM Windows Service registration. + +> Installs Bambuddy natively on Windows using Git, Python, a virtual environment, separate data/log directories, and optional NSSM Windows Service registration. See the [Windows Installer Guide](http://wiki.bambuddy.cool/getting-started/windows-installer/) for parameters and unattended-install options. ### Enabling Developer Mode diff --git a/install/README.md b/install/README.md index 6b1420009..8951e0d1e 100644 --- a/install/README.md +++ b/install/README.md @@ -93,13 +93,13 @@ Installs BamBuddy with Python virtual environment and optional systemd/launchd s ``` ### `windows-installer.ps1` (Windows) -Windows PowerShell: +Windows PowerShell (run as Administrator — the installer self-elevates via UAC if not): ```powershell powershell -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/maziggy/bambuddy/main/install/windows-installer.ps1 -OutFile windows-installer.ps1; .\windows-installer.ps1" - ``` -> Installs Bambuddy natively on Windows using Git, Python, a virtual environment, and optional NSSM Windows Service registration. + +> Installs Bambuddy natively on Windows using Git, Python, a virtual environment, and optional NSSM Windows Service registration. See the [Windows Installer Guide](https://wiki.bambuddy.cool/getting-started/windows-installer/) for full parameter reference. **Parameters:** ```powershell @@ -233,6 +233,15 @@ launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist # Start launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist # Stop ``` +**Windows (NSSM service):** +```powershell +Get-Service Bambuddy # Check status +Start-Service Bambuddy # Start +Stop-Service Bambuddy # Stop +Restart-Service Bambuddy # Restart +Get-Content "C:\Bambuddy\bambuddy-runtime.log" -Tail 100 -Wait # View logs +``` + **Docker:** ```bash docker compose ps # Check status @@ -288,6 +297,13 @@ git pull docker compose up -d --build ``` +**Windows (native):** rerun the installer; it detects the existing checkout and offers `git pull`, leaving `INSTALL_DIR\data` and `INSTALL_DIR\logs` untouched. Stop the service first if it is registered: +```powershell +Stop-Service Bambuddy +.\windows-installer.ps1 -Yes +Start-Service Bambuddy +``` + --- ## Troubleshooting @@ -318,6 +334,22 @@ Choose a different port during installation or stop the conflicting service: sudo lsof -i :8000 # Linux/macOS ``` +```powershell +# Windows +Get-NetTCPConnection -LocalPort 8000 -State Listen +``` + +### Windows: Service Won't Start +Test the start script manually first: +```powershell +powershell.exe -ExecutionPolicy Bypass -File "C:\Bambuddy\Start-Bambuddy.ps1" +``` + +Then check the NSSM runtime logs: +```powershell +Get-Content "C:\Bambuddy\bambuddy-runtime-error.log" -Tail 100 +``` + --- ## Requirements From cbbd0bf1cc517b7b372b8e62164db8efcb04315e Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 2 Jun 2026 15:51:42 +0200 Subject: [PATCH 35/37] Updated BACKERS --- BACKERS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/BACKERS.md b/BACKERS.md index 6d1161a3f..f9a4912fc 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -23,6 +23,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@VREmma](https://github.com/VREmma) - [@pwostran](https://github.com/pwostran) +- [@Praxeis](https://github.com/Praxeis) ## Supporters ($15/mo+) @@ -30,6 +31,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@rstocks](https://github.com/rstocks) - [@sixfootseven](https://github.com/sixfootseven) - [@MethodicalMartian](https://github.com/MethodicalMartian) +- [@jmclaren7](https://github.com/jmclaren7) ## Backers ($5/mo+) @@ -41,6 +43,9 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@brianehlert](https://github.com/brianehlert) - [@siiruup](https://github.com/siiruup) - [@agntcoopersea](https://github.com/agntcoopersea) +- [@PJMCL1618033](https://github.com/PJMCL1618033 +- [@mgf99](https://github.com/mgf99) + --- ## One-time and historical supporters From 9dc326c892a27fe02bdcb75ff9acf24d95853f48 Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 3 Jun 2026 11:33:36 +0200 Subject: [PATCH 36/37] Updated README --- .cache/matplotlib/fontlist-v390.json | 2284 ++++++++++++++++++++++++++ README.md | 23 +- 2 files changed, 2295 insertions(+), 12 deletions(-) create mode 100644 .cache/matplotlib/fontlist-v390.json diff --git a/.cache/matplotlib/fontlist-v390.json b/.cache/matplotlib/fontlist-v390.json new file mode 100644 index 000000000..7890a5a75 --- /dev/null +++ b/.cache/matplotlib/fontlist-v390.json @@ -0,0 +1,2284 @@ +{ + "_version": 390, + "_FontManager__default_weight": "normal", + "default_size": null, + "defaultFamily": { + "ttf": "DejaVu Sans", + "afm": "Helvetica" + }, + "afmlist": [ + { + "fname": "fonts/afm/ptmri8a.afm", + "name": "Times", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/cmmi10.afm", + "name": "Computer Modern", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pagdo8a.afm", + "name": "ITC Avant Garde Gothic", + "style": "italic", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pncri8a.afm", + "name": "New Century Schoolbook", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pzdr.afm", + "name": "ITC Zapf Dingbats", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/cmr10.afm", + "name": "Computer Modern", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/cmex10.afm", + "name": "Computer Modern", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pplri8a.afm", + "name": "Palatino", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pbkd8a.afm", + "name": "ITC Bookman", + "style": "normal", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/ptmr8a.afm", + "name": "Times", + "style": "normal", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/cmtt10.afm", + "name": "Computer Modern", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/ptmbi8a.afm", + "name": "Times", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Helvetica-Oblique.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Helvetica-BoldOblique.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pncbi8a.afm", + "name": "New Century Schoolbook", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Courier-BoldOblique.afm", + "name": "Courier", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvbo8an.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvr8a.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pzcmi8a.afm", + "name": "ITC Zapf Chancery", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Courier-Bold.afm", + "name": "Courier", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/psyr.afm", + "name": "Symbol", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvb8an.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/putb8a.afm", + "name": "Utopia", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvbo8a.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Times-BoldItalic.afm", + "name": "Times", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pcrr8a.afm", + "name": "Courier", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pplr8a.afm", + "name": "Palatino", + "style": "normal", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pagk8a.afm", + "name": "ITC Avant Garde Gothic", + "style": "normal", + "variant": "normal", + "weight": "book", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pbkli8a.afm", + "name": "ITC Bookman", + "style": "italic", + "variant": "normal", + "weight": "light", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pncb8a.afm", + "name": "New Century Schoolbook", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Times-Bold.afm", + "name": "Times", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pncr8a.afm", + "name": "New Century Schoolbook", + "style": "normal", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pbkdi8a.afm", + "name": "ITC Bookman", + "style": "italic", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pcrb8a.afm", + "name": "Courier", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/putri8a.afm", + "name": "Utopia", + "style": "italic", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pcrro8a.afm", + "name": "Courier", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Times-Italic.afm", + "name": "Times", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Courier-Oblique.afm", + "name": "Courier", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Helvetica-Bold.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/putr8a.afm", + "name": "Utopia", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/cmsy10.afm", + "name": "Computer Modern", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvl8a.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "light", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pplb8a.afm", + "name": "Palatino", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvro8a.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvro8an.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pplbi8a.afm", + "name": "Palatino", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pagko8a.afm", + "name": "ITC Avant Garde Gothic", + "style": "italic", + "variant": "normal", + "weight": "book", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/putbi8a.afm", + "name": "Utopia", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Helvetica.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pcrbo8a.afm", + "name": "Courier", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/ptmb8a.afm", + "name": "Times", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pbkl8a.afm", + "name": "ITC Bookman", + "style": "normal", + "variant": "normal", + "weight": "light", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Times-Roman.afm", + "name": "Times", + "style": "normal", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvb8a.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Courier.afm", + "name": "Courier", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/Symbol.afm", + "name": "Symbol", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/pagd8a.afm", + "name": "ITC Avant Garde Gothic", + "style": "normal", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/pdfcorefonts/ZapfDingbats.afm", + "name": "ZapfDingbats", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvlo8a.afm", + "name": "Helvetica", + "style": "italic", + "variant": "normal", + "weight": "light", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/afm/phvr8an.afm", + "name": "Helvetica", + "style": "normal", + "variant": "normal", + "weight": "medium", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-BoldItalic.afm", + "name": "Nimbus Roman", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0632bt_.afm", + "name": "Bitstream Charter", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-Regular.afm", + "name": "Nimbus Sans", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/P052-BoldItalic.afm", + "name": "P052", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0583bt_.afm", + "name": "Courier 10 Pitch", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0648bt_.afm", + "name": "Bitstream Charter", + "style": "normal", + "variant": "normal", + "weight": "normal", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/C059-Italic.afm", + "name": "C059", + "style": "italic", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-Demi.afm", + "name": "URW Gothic", + "style": "normal", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-BoldItalic.afm", + "name": "Nimbus Sans", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-Bold.afm", + "name": "Nimbus Mono PS", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-Bold.afm", + "name": "Nimbus Sans", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0649bt_.afm", + "name": "Bitstream Charter", + "style": "italic", + "variant": "normal", + "weight": "normal", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-LightItalic.afm", + "name": "URW Bookman", + "style": "italic", + "variant": "normal", + "weight": "light", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-Regular.afm", + "name": "Nimbus Sans Narrow", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-BoldOblique.afm", + "name": "Nimbus Sans Narrow", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-BoldItalic.afm", + "name": "Nimbus Mono PS", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-Book.afm", + "name": "URW Gothic", + "style": "normal", + "variant": "normal", + "weight": "book", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-Bold.afm", + "name": "Nimbus Sans Narrow", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0419bt_.afm", + "name": "Courier 10 Pitch", + "style": "normal", + "variant": "normal", + "weight": "normal", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-DemiItalic.afm", + "name": "URW Bookman", + "style": "italic", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-Regular.afm", + "name": "Nimbus Mono PS", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-Oblique.afm", + "name": "Nimbus Sans Narrow", + "style": "italic", + "variant": "normal", + "weight": "regular", + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-Bold.afm", + "name": "Nimbus Roman", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/C059-Roman.afm", + "name": "C059", + "style": "normal", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/P052-Bold.afm", + "name": "P052", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-Italic.afm", + "name": "Nimbus Sans", + "style": "italic", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/P052-Italic.afm", + "name": "P052", + "style": "italic", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-BookOblique.afm", + "name": "URW Gothic", + "style": "italic", + "variant": "normal", + "weight": "book", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/C059-BdIta.afm", + "name": "C059", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/C059-Bold.afm", + "name": "C059", + "style": "normal", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-Regular.afm", + "name": "Nimbus Roman", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-Italic.afm", + "name": "Nimbus Mono PS", + "style": "italic", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-Italic.afm", + "name": "Nimbus Roman", + "style": "italic", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/P052-Roman.afm", + "name": "P052", + "style": "normal", + "variant": "normal", + "weight": "roman", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/D050000L.afm", + "name": "D050000L", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-DemiOblique.afm", + "name": "URW Gothic", + "style": "italic", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/StandardSymbolsPS.afm", + "name": "Standard Symbols PS", + "style": "normal", + "variant": "normal", + "weight": "regular", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0582bt_.afm", + "name": "Courier 10 Pitch", + "style": "italic", + "variant": "normal", + "weight": "normal", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-Light.afm", + "name": "URW Bookman", + "style": "normal", + "variant": "normal", + "weight": "light", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0611bt_.afm", + "name": "Courier 10 Pitch", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/X11/Type1/c0633bt_.afm", + "name": "Bitstream Charter", + "style": "italic", + "variant": "normal", + "weight": "bold", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-Demi.afm", + "name": "URW Bookman", + "style": "normal", + "variant": "normal", + "weight": "demi", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/type1/urw-base35/Z003-MediumItalic.afm", + "name": "Z003", + "style": "italic", + "variant": "normal", + "weight": "medium", + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + } + ], + "ttflist": [ + { + "fname": "fonts/ttf/cmex10.ttf", + "name": "cmex10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizThreeSymBol.ttf", + "name": "STIXSizeThreeSym", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizFourSymBol.ttf", + "name": "STIXSizeFourSym", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSans.ttf", + "name": "DejaVu Sans", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/cmtt10.ttf", + "name": "cmtt10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSans-Bold.ttf", + "name": "DejaVu Sans", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXNonUniBol.ttf", + "name": "STIXNonUnicode", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizThreeSymReg.ttf", + "name": "STIXSizeThreeSym", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSansMono-Oblique.ttf", + "name": "DejaVu Sans Mono", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSerif.ttf", + "name": "DejaVu Serif", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSerif-Italic.ttf", + "name": "DejaVu Serif", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXNonUniBolIta.ttf", + "name": "STIXNonUnicode", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSansMono-BoldOblique.ttf", + "name": "DejaVu Sans Mono", + "style": "oblique", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/cmsy10.ttf", + "name": "cmsy10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSerif-BoldItalic.ttf", + "name": "DejaVu Serif", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSerif-Bold.ttf", + "name": "DejaVu Serif", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSansMono.ttf", + "name": "DejaVu Sans Mono", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXGeneralBol.ttf", + "name": "STIXGeneral", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/cmmi10.ttf", + "name": "cmmi10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizFourSymReg.ttf", + "name": "STIXSizeFourSym", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSans-Oblique.ttf", + "name": "DejaVu Sans", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizOneSymReg.ttf", + "name": "STIXSizeOneSym", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXGeneral.ttf", + "name": "STIXGeneral", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXGeneralItalic.ttf", + "name": "STIXGeneral", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXGeneralBolIta.ttf", + "name": "STIXGeneral", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/cmss10.ttf", + "name": "cmss10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSans-BoldOblique.ttf", + "name": "DejaVu Sans", + "style": "oblique", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizTwoSymReg.ttf", + "name": "STIXSizeTwoSym", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSansDisplay.ttf", + "name": "DejaVu Sans Display", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/cmr10.ttf", + "name": "cmr10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizOneSymBol.ttf", + "name": "STIXSizeOneSym", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/cmb10.ttf", + "name": "cmb10", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizTwoSymBol.ttf", + "name": "STIXSizeTwoSym", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXSizFiveSymReg.ttf", + "name": "STIXSizeFiveSym", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSansMono-Bold.ttf", + "name": "DejaVu Sans Mono", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXNonUni.ttf", + "name": "STIXNonUnicode", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/STIXNonUniIta.ttf", + "name": "STIXNonUnicode", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "fonts/ttf/DejaVuSerifDisplay.ttf", + "name": "DejaVu Serif Display", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSerifBoldItalic.ttf", + "name": "FreeSerif", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", + "name": "IPAGothic", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", + "name": "DejaVu Sans Mono", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-Bold.otf", + "name": "Nimbus Mono PS", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf", + "name": "Liberation Serif", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-LightItalic.otf", + "name": "URW Bookman", + "style": "italic", + "variant": "normal", + "weight": 300, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/C059-BdIta.otf", + "name": "C059", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf", + "name": "Nimbus Sans", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-Bold.otf", + "name": "Nimbus Roman", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/tlwg/Loma-Bold.otf", + "name": "Loma", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "name": "DejaVu Sans", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/P052-Roman.otf", + "name": "P052", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-Italic.otf", + "name": "Nimbus Mono PS", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont_jp_sample.otf", + "name": "Unifont Sample", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", + "name": "Liberation Serif", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont.otf", + "name": "Unifont", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-BookOblique.otf", + "name": "URW Gothic", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-Bold.otf", + "name": "Nimbus Sans Narrow", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont_upper_sample.otf", + "name": "Unifont Sample", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + "name": "WenQuanYi Zen Hei", + "style": "normal", + "variant": "normal", + "weight": 500, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "name": "DejaVu Sans Mono", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", + "name": "Liberation Serif", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-BoldOblique.ttf", + "name": "DejaVu Sans Mono", + "style": "oblique", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", + "name": "IPAGothic", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "name": "Liberation Sans", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-Book.otf", + "name": "URW Gothic", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-Oblique.otf", + "name": "Nimbus Sans Narrow", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-Regular.otf", + "name": "Nimbus Sans Narrow", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeMono.ttf", + "name": "FreeMono", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-Regular.otf", + "name": "Nimbus Roman", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf", + "name": "Liberation Sans", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf", + "name": "Noto Sans Mono", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", + "name": "DejaVu Serif", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf", + "name": "IPAPGothic", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-BoldItalic.ttf", + "name": "Liberation Mono", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf", + "name": "Liberation Serif", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-BoldItalic.otf", + "name": "Nimbus Sans", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-Demi.otf", + "name": "URW Gothic", + "style": "normal", + "variant": "normal", + "weight": 600, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/noto/NotoMono-Regular.ttf", + "name": "Noto Mono", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-Italic.otf", + "name": "Nimbus Roman", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/C059-Italic.otf", + "name": "C059", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", + "name": "DejaVu Serif", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-Italic.otf", + "name": "Nimbus Sans", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/P052-Italic.otf", + "name": "P052", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont_sample.otf", + "name": "Unifont Sample", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeMonoBoldOblique.ttf", + "name": "FreeMono", + "style": "oblique", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeMonoOblique.ttf", + "name": "FreeMono", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-BoldItalic.otf", + "name": "Nimbus Mono PS", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSerif.ttf", + "name": "FreeSerif", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/Z003-MediumItalic.otf", + "name": "Z003", + "style": "italic", + "variant": "normal", + "weight": 500, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf", + "name": "FreeMono", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf", + "name": "Liberation Sans", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/tlwg/Loma-Oblique.otf", + "name": "Loma", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/tlwg/Loma-BoldOblique.otf", + "name": "Loma", + "style": "oblique", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSansBoldOblique.ttf", + "name": "FreeSans", + "style": "oblique", + "variant": "normal", + "weight": 600, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-BoldItalic.otf", + "name": "Nimbus Roman", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-DemiItalic.otf", + "name": "URW Bookman", + "style": "italic", + "variant": "normal", + "weight": 600, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSerifItalic.ttf", + "name": "FreeSerif", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSansOblique.ttf", + "name": "FreeSans", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-Bold.otf", + "name": "Nimbus Sans", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/D050000L.otf", + "name": "D050000L", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", + "name": "FreeSans", + "style": "normal", + "variant": "normal", + "weight": 600, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-DemiOblique.otf", + "name": "URW Gothic", + "style": "oblique", + "variant": "normal", + "weight": 600, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "name": "Liberation Sans", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf", + "name": "FreeSerif", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + "name": "Droid Sans Fallback", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/P052-Bold.otf", + "name": "P052", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/tlwg/Loma.otf", + "name": "Loma", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf", + "name": "Liberation Mono", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-Demi.otf", + "name": "URW Bookman", + "style": "normal", + "variant": "normal", + "weight": 600, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-Light.otf", + "name": "URW Bookman", + "style": "normal", + "variant": "normal", + "weight": 300, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/StandardSymbolsPS.otf", + "name": "Standard Symbols PS", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/C059-Roman.otf", + "name": "C059", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/C059-Bold.otf", + "name": "C059", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-BoldOblique.otf", + "name": "Nimbus Sans Narrow", + "style": "oblique", + "variant": "normal", + "weight": 700, + "stretch": "condensed", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont_upper.otf", + "name": "Unifont Upper", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Oblique.ttf", + "name": "DejaVu Sans Mono", + "style": "oblique", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf", + "name": "Noto Sans Mono", + "style": "normal", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-Italic.ttf", + "name": "Liberation Mono", + "style": "italic", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + "name": "FreeSans", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/P052-BoldItalic.otf", + "name": "P052", + "style": "italic", + "variant": "normal", + "weight": 700, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "name": "DejaVu Sans", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont_jp.otf", + "name": "Unifont-JP", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/unifont/unifont_csur.otf", + "name": "Unifont CSUR", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-Regular.otf", + "name": "Nimbus Mono PS", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + }, + { + "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + "name": "Liberation Mono", + "style": "normal", + "variant": "normal", + "weight": 400, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry" + } + ], + "__class__": "FontManager" +} diff --git a/README.md b/README.md index 29fea1c35..43dadb356 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R ### 📁 File Manager (Library) - Upload and organize sliced files (3MF, gcode, STL) -- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files +- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files. Operator-controlled via the `BAMBUDDY_EXTERNAL_ROOTS` env var (colon-separated allowlist of host paths users are permitted to register; empty by default to disable the feature). See [Docker → External library folders](https://wiki.bambuddy.cool/getting-started/docker/#external-library-folders-bambuddy_external_roots). - **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files - ZIP file extraction with folder structure preservation - Option to create folder from ZIP filename @@ -704,22 +704,21 @@ Contributions welcome! Ways to help: 2. **Test** — Report issues with your printer model 3. **Translate** — Add new languages 4. **Code** — Submit PRs for bugs or features +5. **🔒 Security review** — *(specifically wanted, see below)* Not sure where to start? Reach out on [Discord](https://discord.gg/aFS3ZfScHM) or email **martin@bambuddy.cool** — I'll help you find something that fits. -```bash -# Development setup -git clone https://github.com/maziggy/bambuddy.git -cd bambuddy +### 🔒 Looking for a security-focused contributor -# Backend -python3 -m venv venv && source venv/bin/activate -pip install -r requirements.txt -DEBUG=true uvicorn backend.app.main:app --reload +I'm bringing on a contributor whose specific focus is keeping an eye on Bambuddy's security. -# Frontend (separate terminal) -cd frontend && npm install && npm run dev -``` +Concretely: + +Track the `dev` branch and flag changes touching auth, permissions, token handling, or the CI security backstops. Async post-merge — no gating of in-flight PRs. + +What matters more than formal qualifications: fail-closed thinking by default, comfortable reading the auth layer (FastAPI + SQLAlchemy on the backend, a small React surface), willing to push back on `except Exception` shapes in security-sensitive code. + +No fixed time commitment. If you're interested — or know someone who fits — email `martin@bambuddy.cool` or DM on Discord. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. From 56be4d4647c04347a25a9400dbca8bdc8145e89c Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 3 Jun 2026 14:11:37 +0200 Subject: [PATCH 37/37] Housekeeping --- .cache/matplotlib/fontlist-v390.json | 2284 -------------------------- 1 file changed, 2284 deletions(-) delete mode 100644 .cache/matplotlib/fontlist-v390.json diff --git a/.cache/matplotlib/fontlist-v390.json b/.cache/matplotlib/fontlist-v390.json deleted file mode 100644 index 7890a5a75..000000000 --- a/.cache/matplotlib/fontlist-v390.json +++ /dev/null @@ -1,2284 +0,0 @@ -{ - "_version": 390, - "_FontManager__default_weight": "normal", - "default_size": null, - "defaultFamily": { - "ttf": "DejaVu Sans", - "afm": "Helvetica" - }, - "afmlist": [ - { - "fname": "fonts/afm/ptmri8a.afm", - "name": "Times", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/cmmi10.afm", - "name": "Computer Modern", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pagdo8a.afm", - "name": "ITC Avant Garde Gothic", - "style": "italic", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pncri8a.afm", - "name": "New Century Schoolbook", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pzdr.afm", - "name": "ITC Zapf Dingbats", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/cmr10.afm", - "name": "Computer Modern", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/cmex10.afm", - "name": "Computer Modern", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pplri8a.afm", - "name": "Palatino", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pbkd8a.afm", - "name": "ITC Bookman", - "style": "normal", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/ptmr8a.afm", - "name": "Times", - "style": "normal", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/cmtt10.afm", - "name": "Computer Modern", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/ptmbi8a.afm", - "name": "Times", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Helvetica-Oblique.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Helvetica-BoldOblique.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pncbi8a.afm", - "name": "New Century Schoolbook", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Courier-BoldOblique.afm", - "name": "Courier", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvbo8an.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvr8a.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pzcmi8a.afm", - "name": "ITC Zapf Chancery", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Courier-Bold.afm", - "name": "Courier", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/psyr.afm", - "name": "Symbol", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvb8an.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/putb8a.afm", - "name": "Utopia", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvbo8a.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Times-BoldItalic.afm", - "name": "Times", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pcrr8a.afm", - "name": "Courier", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pplr8a.afm", - "name": "Palatino", - "style": "normal", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pagk8a.afm", - "name": "ITC Avant Garde Gothic", - "style": "normal", - "variant": "normal", - "weight": "book", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pbkli8a.afm", - "name": "ITC Bookman", - "style": "italic", - "variant": "normal", - "weight": "light", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pncb8a.afm", - "name": "New Century Schoolbook", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Times-Bold.afm", - "name": "Times", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pncr8a.afm", - "name": "New Century Schoolbook", - "style": "normal", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pbkdi8a.afm", - "name": "ITC Bookman", - "style": "italic", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pcrb8a.afm", - "name": "Courier", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/putri8a.afm", - "name": "Utopia", - "style": "italic", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pcrro8a.afm", - "name": "Courier", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Times-Italic.afm", - "name": "Times", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Courier-Oblique.afm", - "name": "Courier", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Helvetica-Bold.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/putr8a.afm", - "name": "Utopia", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/cmsy10.afm", - "name": "Computer Modern", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvl8a.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "light", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pplb8a.afm", - "name": "Palatino", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvro8a.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvro8an.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pplbi8a.afm", - "name": "Palatino", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pagko8a.afm", - "name": "ITC Avant Garde Gothic", - "style": "italic", - "variant": "normal", - "weight": "book", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/putbi8a.afm", - "name": "Utopia", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Helvetica.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pcrbo8a.afm", - "name": "Courier", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/ptmb8a.afm", - "name": "Times", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pbkl8a.afm", - "name": "ITC Bookman", - "style": "normal", - "variant": "normal", - "weight": "light", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Times-Roman.afm", - "name": "Times", - "style": "normal", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvb8a.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Courier.afm", - "name": "Courier", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/Symbol.afm", - "name": "Symbol", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/pagd8a.afm", - "name": "ITC Avant Garde Gothic", - "style": "normal", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/pdfcorefonts/ZapfDingbats.afm", - "name": "ZapfDingbats", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvlo8a.afm", - "name": "Helvetica", - "style": "italic", - "variant": "normal", - "weight": "light", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/afm/phvr8an.afm", - "name": "Helvetica", - "style": "normal", - "variant": "normal", - "weight": "medium", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-BoldItalic.afm", - "name": "Nimbus Roman", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0632bt_.afm", - "name": "Bitstream Charter", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-Regular.afm", - "name": "Nimbus Sans", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/P052-BoldItalic.afm", - "name": "P052", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0583bt_.afm", - "name": "Courier 10 Pitch", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0648bt_.afm", - "name": "Bitstream Charter", - "style": "normal", - "variant": "normal", - "weight": "normal", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/C059-Italic.afm", - "name": "C059", - "style": "italic", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-Demi.afm", - "name": "URW Gothic", - "style": "normal", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-BoldItalic.afm", - "name": "Nimbus Sans", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-Bold.afm", - "name": "Nimbus Mono PS", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-Bold.afm", - "name": "Nimbus Sans", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0649bt_.afm", - "name": "Bitstream Charter", - "style": "italic", - "variant": "normal", - "weight": "normal", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-LightItalic.afm", - "name": "URW Bookman", - "style": "italic", - "variant": "normal", - "weight": "light", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-Regular.afm", - "name": "Nimbus Sans Narrow", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-BoldOblique.afm", - "name": "Nimbus Sans Narrow", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-BoldItalic.afm", - "name": "Nimbus Mono PS", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-Book.afm", - "name": "URW Gothic", - "style": "normal", - "variant": "normal", - "weight": "book", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-Bold.afm", - "name": "Nimbus Sans Narrow", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0419bt_.afm", - "name": "Courier 10 Pitch", - "style": "normal", - "variant": "normal", - "weight": "normal", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-DemiItalic.afm", - "name": "URW Bookman", - "style": "italic", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-Regular.afm", - "name": "Nimbus Mono PS", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSansNarrow-Oblique.afm", - "name": "Nimbus Sans Narrow", - "style": "italic", - "variant": "normal", - "weight": "regular", - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-Bold.afm", - "name": "Nimbus Roman", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/C059-Roman.afm", - "name": "C059", - "style": "normal", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/P052-Bold.afm", - "name": "P052", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusSans-Italic.afm", - "name": "Nimbus Sans", - "style": "italic", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/P052-Italic.afm", - "name": "P052", - "style": "italic", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-BookOblique.afm", - "name": "URW Gothic", - "style": "italic", - "variant": "normal", - "weight": "book", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/C059-BdIta.afm", - "name": "C059", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/C059-Bold.afm", - "name": "C059", - "style": "normal", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-Regular.afm", - "name": "Nimbus Roman", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusMonoPS-Italic.afm", - "name": "Nimbus Mono PS", - "style": "italic", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/NimbusRoman-Italic.afm", - "name": "Nimbus Roman", - "style": "italic", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/P052-Roman.afm", - "name": "P052", - "style": "normal", - "variant": "normal", - "weight": "roman", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/D050000L.afm", - "name": "D050000L", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWGothic-DemiOblique.afm", - "name": "URW Gothic", - "style": "italic", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/StandardSymbolsPS.afm", - "name": "Standard Symbols PS", - "style": "normal", - "variant": "normal", - "weight": "regular", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0582bt_.afm", - "name": "Courier 10 Pitch", - "style": "italic", - "variant": "normal", - "weight": "normal", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-Light.afm", - "name": "URW Bookman", - "style": "normal", - "variant": "normal", - "weight": "light", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0611bt_.afm", - "name": "Courier 10 Pitch", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/X11/Type1/c0633bt_.afm", - "name": "Bitstream Charter", - "style": "italic", - "variant": "normal", - "weight": "bold", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/URWBookman-Demi.afm", - "name": "URW Bookman", - "style": "normal", - "variant": "normal", - "weight": "demi", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/type1/urw-base35/Z003-MediumItalic.afm", - "name": "Z003", - "style": "italic", - "variant": "normal", - "weight": "medium", - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - } - ], - "ttflist": [ - { - "fname": "fonts/ttf/cmex10.ttf", - "name": "cmex10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizThreeSymBol.ttf", - "name": "STIXSizeThreeSym", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizFourSymBol.ttf", - "name": "STIXSizeFourSym", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSans.ttf", - "name": "DejaVu Sans", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/cmtt10.ttf", - "name": "cmtt10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSans-Bold.ttf", - "name": "DejaVu Sans", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXNonUniBol.ttf", - "name": "STIXNonUnicode", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizThreeSymReg.ttf", - "name": "STIXSizeThreeSym", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSansMono-Oblique.ttf", - "name": "DejaVu Sans Mono", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSerif.ttf", - "name": "DejaVu Serif", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSerif-Italic.ttf", - "name": "DejaVu Serif", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXNonUniBolIta.ttf", - "name": "STIXNonUnicode", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSansMono-BoldOblique.ttf", - "name": "DejaVu Sans Mono", - "style": "oblique", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/cmsy10.ttf", - "name": "cmsy10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSerif-BoldItalic.ttf", - "name": "DejaVu Serif", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSerif-Bold.ttf", - "name": "DejaVu Serif", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSansMono.ttf", - "name": "DejaVu Sans Mono", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXGeneralBol.ttf", - "name": "STIXGeneral", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/cmmi10.ttf", - "name": "cmmi10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizFourSymReg.ttf", - "name": "STIXSizeFourSym", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSans-Oblique.ttf", - "name": "DejaVu Sans", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizOneSymReg.ttf", - "name": "STIXSizeOneSym", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXGeneral.ttf", - "name": "STIXGeneral", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXGeneralItalic.ttf", - "name": "STIXGeneral", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXGeneralBolIta.ttf", - "name": "STIXGeneral", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/cmss10.ttf", - "name": "cmss10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSans-BoldOblique.ttf", - "name": "DejaVu Sans", - "style": "oblique", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizTwoSymReg.ttf", - "name": "STIXSizeTwoSym", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSansDisplay.ttf", - "name": "DejaVu Sans Display", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/cmr10.ttf", - "name": "cmr10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizOneSymBol.ttf", - "name": "STIXSizeOneSym", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/cmb10.ttf", - "name": "cmb10", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizTwoSymBol.ttf", - "name": "STIXSizeTwoSym", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXSizFiveSymReg.ttf", - "name": "STIXSizeFiveSym", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSansMono-Bold.ttf", - "name": "DejaVu Sans Mono", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXNonUni.ttf", - "name": "STIXNonUnicode", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/STIXNonUniIta.ttf", - "name": "STIXNonUnicode", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "fonts/ttf/DejaVuSerifDisplay.ttf", - "name": "DejaVu Serif Display", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSerifBoldItalic.ttf", - "name": "FreeSerif", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", - "name": "IPAGothic", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", - "name": "DejaVu Sans Mono", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-Bold.otf", - "name": "Nimbus Mono PS", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf", - "name": "Liberation Serif", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-LightItalic.otf", - "name": "URW Bookman", - "style": "italic", - "variant": "normal", - "weight": 300, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/C059-BdIta.otf", - "name": "C059", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf", - "name": "Nimbus Sans", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-Bold.otf", - "name": "Nimbus Roman", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/tlwg/Loma-Bold.otf", - "name": "Loma", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - "name": "DejaVu Sans", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/P052-Roman.otf", - "name": "P052", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-Italic.otf", - "name": "Nimbus Mono PS", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont_jp_sample.otf", - "name": "Unifont Sample", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", - "name": "Liberation Serif", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont.otf", - "name": "Unifont", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-BookOblique.otf", - "name": "URW Gothic", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-Bold.otf", - "name": "Nimbus Sans Narrow", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont_upper_sample.otf", - "name": "Unifont Sample", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", - "name": "WenQuanYi Zen Hei", - "style": "normal", - "variant": "normal", - "weight": 500, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - "name": "DejaVu Sans Mono", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", - "name": "Liberation Serif", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-BoldOblique.ttf", - "name": "DejaVu Sans Mono", - "style": "oblique", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", - "name": "IPAGothic", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "name": "Liberation Sans", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-Book.otf", - "name": "URW Gothic", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-Oblique.otf", - "name": "Nimbus Sans Narrow", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-Regular.otf", - "name": "Nimbus Sans Narrow", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeMono.ttf", - "name": "FreeMono", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-Regular.otf", - "name": "Nimbus Roman", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf", - "name": "Liberation Sans", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf", - "name": "Noto Sans Mono", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", - "name": "DejaVu Serif", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf", - "name": "IPAPGothic", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-BoldItalic.ttf", - "name": "Liberation Mono", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf", - "name": "Liberation Serif", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-BoldItalic.otf", - "name": "Nimbus Sans", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-Demi.otf", - "name": "URW Gothic", - "style": "normal", - "variant": "normal", - "weight": 600, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/noto/NotoMono-Regular.ttf", - "name": "Noto Mono", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-Italic.otf", - "name": "Nimbus Roman", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/C059-Italic.otf", - "name": "C059", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", - "name": "DejaVu Serif", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-Italic.otf", - "name": "Nimbus Sans", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/P052-Italic.otf", - "name": "P052", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont_sample.otf", - "name": "Unifont Sample", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeMonoBoldOblique.ttf", - "name": "FreeMono", - "style": "oblique", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeMonoOblique.ttf", - "name": "FreeMono", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-BoldItalic.otf", - "name": "Nimbus Mono PS", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSerif.ttf", - "name": "FreeSerif", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/Z003-MediumItalic.otf", - "name": "Z003", - "style": "italic", - "variant": "normal", - "weight": 500, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf", - "name": "FreeMono", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf", - "name": "Liberation Sans", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/tlwg/Loma-Oblique.otf", - "name": "Loma", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/tlwg/Loma-BoldOblique.otf", - "name": "Loma", - "style": "oblique", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSansBoldOblique.ttf", - "name": "FreeSans", - "style": "oblique", - "variant": "normal", - "weight": 600, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusRoman-BoldItalic.otf", - "name": "Nimbus Roman", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-DemiItalic.otf", - "name": "URW Bookman", - "style": "italic", - "variant": "normal", - "weight": 600, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSerifItalic.ttf", - "name": "FreeSerif", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSansOblique.ttf", - "name": "FreeSans", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSans-Bold.otf", - "name": "Nimbus Sans", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/D050000L.otf", - "name": "D050000L", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", - "name": "FreeSans", - "style": "normal", - "variant": "normal", - "weight": 600, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWGothic-DemiOblique.otf", - "name": "URW Gothic", - "style": "oblique", - "variant": "normal", - "weight": 600, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", - "name": "Liberation Sans", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf", - "name": "FreeSerif", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", - "name": "Droid Sans Fallback", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/P052-Bold.otf", - "name": "P052", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/tlwg/Loma.otf", - "name": "Loma", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf", - "name": "Liberation Mono", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-Demi.otf", - "name": "URW Bookman", - "style": "normal", - "variant": "normal", - "weight": 600, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/URWBookman-Light.otf", - "name": "URW Bookman", - "style": "normal", - "variant": "normal", - "weight": 300, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/StandardSymbolsPS.otf", - "name": "Standard Symbols PS", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/C059-Roman.otf", - "name": "C059", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/C059-Bold.otf", - "name": "C059", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusSansNarrow-BoldOblique.otf", - "name": "Nimbus Sans Narrow", - "style": "oblique", - "variant": "normal", - "weight": 700, - "stretch": "condensed", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont_upper.otf", - "name": "Unifont Upper", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Oblique.ttf", - "name": "DejaVu Sans Mono", - "style": "oblique", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf", - "name": "Noto Sans Mono", - "style": "normal", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-Italic.ttf", - "name": "Liberation Mono", - "style": "italic", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - "name": "FreeSans", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/P052-BoldItalic.otf", - "name": "P052", - "style": "italic", - "variant": "normal", - "weight": 700, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "name": "DejaVu Sans", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont_jp.otf", - "name": "Unifont-JP", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/unifont/unifont_csur.otf", - "name": "Unifont CSUR", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/opentype/urw-base35/NimbusMonoPS-Regular.otf", - "name": "Nimbus Mono PS", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - }, - { - "fname": "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", - "name": "Liberation Mono", - "style": "normal", - "variant": "normal", - "weight": 400, - "stretch": "normal", - "size": "scalable", - "__class__": "FontEntry" - } - ], - "__class__": "FontManager" -}