Browse Source

Show announcements from the Bambuddy maintainers

    Fetch a signed feed.json from the public bambuddy-notifications repo on
    GitHub at startup and every 6 hours. Nothing about the install is sent;
    targeting (version, beta channel, install type) is decided locally.

    - Ed25519 against a key built into the app; an older serial is refused so a
      withdrawn message can't come back. The feed replaces the stored list, and
      a failed or rejected fetch keeps the last good one.
    - Sidebar entry above System with an unread count, a slide-over list, and a
      banner for unread important/critical messages. Read state per user.
    - Admins by default; Settings > General > Updates can show them to all
      users or switch them off, which also stops the fetch.
    - Plain text only; links to github.com and bambuddy.cool only.
maziggy 2 days ago
parent
commit
42d1083607
34 changed files with 1960 additions and 1 deletions
  1. 60 0
      backend/app/api/routes/announcements.py
  2. 2 0
      backend/app/api/routes/settings.py
  3. 1 0
      backend/app/core/database.py
  4. 10 0
      backend/app/main.py
  5. 3 0
      backend/app/models/__init__.py
  6. 51 0
      backend/app/models/announcement.py
  7. 8 0
      backend/app/schemas/settings.py
  8. 457 0
      backend/app/services/announcements.py
  9. 134 0
      backend/tests/integration/test_announcements_api.py
  10. 307 0
      backend/tests/unit/test_announcements_service.py
  11. 159 0
      frontend/src/__tests__/components/Announcements.test.tsx
  12. 45 0
      frontend/src/__tests__/hooks/useAnnouncements.test.ts
  13. 5 0
      frontend/src/__tests__/mocks/handlers.ts
  14. 29 0
      frontend/src/api/client.ts
  15. 63 0
      frontend/src/components/AnnouncementBanner.tsx
  16. 129 0
      frontend/src/components/AnnouncementsPanel.tsx
  17. 59 1
      frontend/src/components/Layout.tsx
  18. 85 0
      frontend/src/hooks/useAnnouncements.ts
  19. 20 0
      frontend/src/i18n/locales/de.ts
  20. 20 0
      frontend/src/i18n/locales/en.ts
  21. 20 0
      frontend/src/i18n/locales/es.ts
  22. 20 0
      frontend/src/i18n/locales/fr.ts
  23. 20 0
      frontend/src/i18n/locales/it.ts
  24. 20 0
      frontend/src/i18n/locales/ja.ts
  25. 20 0
      frontend/src/i18n/locales/ko.ts
  26. 20 0
      frontend/src/i18n/locales/nl.ts
  27. 20 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 20 0
      frontend/src/i18n/locales/ru.ts
  29. 20 0
      frontend/src/i18n/locales/sv.ts
  30. 20 0
      frontend/src/i18n/locales/tr.ts
  31. 20 0
      frontend/src/i18n/locales/uk.ts
  32. 20 0
      frontend/src/i18n/locales/zh-CN.ts
  33. 20 0
      frontend/src/i18n/locales/zh-TW.ts
  34. 53 0
      frontend/src/pages/SettingsPage.tsx

+ 60 - 0
backend/app/api/routes/announcements.py

@@ -0,0 +1,60 @@
+"""Announcements from the Bambuddy maintainers (services/announcements.py).
+
+Shown to administrators, and to every signed-in user when the
+``announcements_all_users`` setting is on. With authentication off whoever opens
+Bambuddy runs it, so they see them. Anyone else gets an empty list rather than a
+403: the sidebar entry and the banner are hidden on "nothing to show", which is
+the same answer for "nothing published" and "not for you".
+"""
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import is_auth_enabled, require_auth_if_enabled
+from backend.app.core.database import get_db
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services import announcements as service
+
+router = APIRouter(prefix="/announcements", tags=["announcements"])
+
+
+async def _may_see(db: AsyncSession, user: User | None) -> bool:
+    if not await service.is_enabled(db):
+        return False
+    if not await is_auth_enabled(db):
+        return True
+    # Authenticated by API key: a script, not a person with an inbox.
+    if user is None:
+        return False
+    if user.is_admin:
+        return True
+    all_users = (
+        await db.execute(select(Settings.value).where(Settings.key == service.ALL_USERS_KEY))
+    ).scalar_one_or_none()
+    return (all_users or "").lower() == "true"
+
+
+@router.get("")
+async def list_announcements(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+) -> list[dict]:
+    """Live announcements for this user, newest first, with their read state."""
+    if not await _may_see(db, current_user):
+        return []
+    return await service.list_for(db, current_user.id if current_user else None)
+
+
+@router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)
+async def mark_announcement_read(
+    public_id: str,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+) -> None:
+    if not await _may_see(db, current_user):
+        raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found")
+    if not await service.mark_read(db, public_id, current_user.id if current_user else None):
+        raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found")
+    await db.commit()

+ 2 - 0
backend/app/api/routes/settings.py

@@ -186,6 +186,8 @@ _BOOL_SETTING_KEYS = frozenset(
         "check_updates",
         "check_updates",
         "check_printer_firmware",
         "check_printer_firmware",
         "include_beta_updates",
         "include_beta_updates",
+        "announcements_enabled",
+        "announcements_all_users",
         "virtual_printer_enabled",
         "virtual_printer_enabled",
         "ftp_retry_enabled",
         "ftp_retry_enabled",
         "mqtt_enabled",
         "mqtt_enabled",

+ 1 - 0
backend/app/core/database.py

@@ -288,6 +288,7 @@ async def init_db():
         active_print_spoolman,
         active_print_spoolman,
         ams_history,
         ams_history,
         ams_label,
         ams_label,
+        announcement,
         api_key,
         api_key,
         archive,
         archive,
         auth_ephemeral,
         auth_ephemeral,

+ 10 - 0
backend/app/main.py

@@ -20,6 +20,7 @@ from sqlalchemy import delete, or_, select, text
 
 
 from backend.app.api.routes import (
 from backend.app.api.routes import (
     ams_history,
     ams_history,
+    announcements,
     api_keys,
     api_keys,
     archive_purge,
     archive_purge,
     archives,
     archives,
@@ -9978,6 +9979,11 @@ async def lifespan(app: FastAPI):
     # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
     # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
     start_auth_cleanup()
     start_auth_cleanup()
 
 
+    # Maintainer announcements: a signed feed fetched from GitHub every few hours.
+    from backend.app.services import announcements as announcements_service
+
+    announcements_service.start()
+
     from backend.app.services.printer_media import start_printer_download_cleanup
     from backend.app.services.printer_media import start_printer_download_cleanup
 
 
     start_printer_download_cleanup()
     start_printer_download_cleanup()
@@ -10032,6 +10038,9 @@ async def lifespan(app: FastAPI):
         logging.warning("Failed to shut down camera broadcasters: %s", e)
         logging.warning("Failed to shut down camera broadcasters: %s", e)
     stop_expected_prints_cleanup()
     stop_expected_prints_cleanup()
     stop_auth_cleanup()
     stop_auth_cleanup()
+    from backend.app.services import announcements as announcements_service
+
+    announcements_service.stop()
     from backend.app.services.printer_media import stop_printer_download_cleanup
     from backend.app.services.printer_media import stop_printer_download_cleanup
 
 
     await stop_printer_download_cleanup()
     await stop_printer_download_cleanup()
@@ -10586,6 +10595,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
 app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
+app.include_router(announcements.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(camwall.router, prefix=app_settings.api_prefix)
 app.include_router(camwall.router, prefix=app_settings.api_prefix)

+ 3 - 0
backend/app/models/__init__.py

@@ -1,5 +1,6 @@
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
+from backend.app.models.announcement import Announcement, AnnouncementRead
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
@@ -97,6 +98,8 @@ __all__ = [
     "ColorCatalogEntry",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "SpoolBuddyDevice",
     "SponsorToastState",
     "SponsorToastState",
+    "Announcement",
+    "AnnouncementRead",
     "UserEmailPreference",
     "UserEmailPreference",
     "UserOTPCode",
     "UserOTPCode",
     "UserTOTP",
     "UserTOTP",

+ 51 - 0
backend/app/models/announcement.py

@@ -0,0 +1,51 @@
+"""Announcements from the Bambuddy maintainers, and who has read them.
+
+Fetched from a signed feed on GitHub (services/announcements.py). Only the ones
+that target this install are stored, and the feed is authoritative: a message
+withdrawn upstream is deleted here on the next fetch, together with its read
+markers.
+
+``AnnouncementRead.user_id`` is nullable: with authentication off there are no
+users, and the install's read state is a single NULL-keyed row per message.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class Announcement(Base):
+    __tablename__ = "announcements"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    # The feed's id for the message. Stable across edits upstream, so a corrected
+    # typo does not come back as unread.
+    public_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+    level: Mapped[str] = mapped_column(String(16))  # info | important | critical
+    # JSON {"en": {"title", "body", "link_label"?}, ...}. Text for SQLite/Postgres
+    # uniformity; the service serialises with json.dumps.
+    texts: Mapped[str] = mapped_column(Text)
+    link_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    # Naive UTC, like every other timestamp in Bambuddy.
+    published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+class AnnouncementRead(Base):
+    __tablename__ = "announcement_reads"
+    __table_args__ = (UniqueConstraint("announcement_id", "user_id", name="uq_announcement_reads_user"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    announcement_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("announcements.id", ondelete="CASCADE"), index=True
+    )
+    user_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
+    )
+    read_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 8 - 0
backend/app/schemas/settings.py

@@ -96,6 +96,12 @@ class AppSettings(BaseModel):
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
     check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
     check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
+    announcements_enabled: bool = Field(
+        default=True, description="Fetch announcements from the Bambuddy maintainers (a signed file on GitHub)"
+    )
+    announcements_all_users: bool = Field(
+        default=False, description="Show announcements to every signed-in user, not only administrators"
+    )
 
 
     # Language
     # Language
     language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
     language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
@@ -696,6 +702,8 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
     include_beta_updates: bool | None = None
+    announcements_enabled: bool | None = None
+    announcements_all_users: bool | None = None
     local_login_enabled: bool | None = None
     local_login_enabled: bool | None = None
     language: str | None = None
     language: str | None = None
     notification_language: str | None = None
     notification_language: str | None = None

+ 457 - 0
backend/app/services/announcements.py

@@ -0,0 +1,457 @@
+"""Announcements from the Bambuddy maintainers: fetch, verify, keep what applies.
+
+Bambuddy fetches one file, ``feed.json``, from the public
+``maziggy/bambuddy-notifications`` repo on raw.githubusercontent.com -- the host
+the update check already talks to. No Bambuddy server is contacted and nothing
+about this install is sent: whether a message applies here (version range,
+channel, install type) is decided below, locally.
+
+The file is written by the maintainers' registrar and signed with Ed25519:
+
+    {"format": 1, "key_id": "...", "signature": "<base64>", "payload": {...}}
+
+The signature covers ``canonical(payload)``. A file that does not verify against
+a key in ``TRUSTED_KEYS`` is ignored, so neither a copy of the repo nor anyone in
+the middle can make Bambuddy show a message. The payload's ``serial`` only goes
+up; a feed older than one already accepted is refused, so an old signed file
+cannot be re-served to bring back a withdrawn message.
+
+The feed is the full current list. On every accepted fetch the stored set is
+replaced: a message withdrawn upstream disappears here with its read markers.
+Any failure keeps the last good list.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import logging
+import random
+import re
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from urllib.parse import urlsplit
+
+import httpx
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION
+from backend.app.models.announcement import Announcement, AnnouncementRead
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+FEED_URL = "https://raw.githubusercontent.com/maziggy/bambuddy-notifications/main/feed.json"
+FEED_FORMAT = 1
+
+# key_id (first 16 hex of sha256 of the raw public key) -> base64 raw public key.
+# A list so the key can be rotated: ship the new key next to the old one first.
+TRUSTED_KEYS: dict[str, str] = {
+    "d70b3bf207fdfd59": "uQqUAYrInmXQ1nIwOIY/95L7tD5o/HZMmZfhAlULJ/w=",
+}
+
+# Where a message may link to. The registrar refuses anything else too; this is
+# the side that counts.
+LINK_HOSTS = ("github.com", "bambuddy.cool")
+
+LEVELS = ("info", "important", "critical")
+MAX_FEED_BYTES = 512 * 1024
+MAX_TITLE = 120
+MAX_BODY = 2000
+MAX_LINK_LABEL = 40
+
+FETCH_INTERVAL_SECONDS = 6 * 3600
+FETCH_JITTER_SECONDS = 30 * 60
+# Let startup settle before the first fetch.
+FIRST_FETCH_DELAY_SECONDS = 60
+
+ENABLED_KEY = "announcements_enabled"
+ALL_USERS_KEY = "announcements_all_users"
+# Internal state, never part of the settings API.
+SERIAL_KEY = "announcements_feed_serial"
+LAST_FETCH_KEY = "announcements_last_fetch"
+
+
+class FeedRejected(Exception):
+    """The fetched file is not a feed this install accepts. Nothing changes."""
+
+
+# ---- verification -------------------------------------------------------------------
+
+
+def canonical(payload: dict) -> bytes:
+    return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
+
+
+def verify_feed(content: bytes, trusted_keys: dict[str, str] | None = None) -> dict:
+    """The payload of a correctly signed feed, or FeedRejected."""
+    keys = TRUSTED_KEYS if trusted_keys is None else trusted_keys
+    try:
+        envelope = json.loads(content)
+    except (ValueError, UnicodeDecodeError) as exc:
+        raise FeedRejected("not JSON") from exc
+    if not isinstance(envelope, dict) or envelope.get("format") != FEED_FORMAT:
+        raise FeedRejected("unknown feed format")
+    public = keys.get(str(envelope.get("key_id")))
+    if public is None:
+        raise FeedRejected(f"signed with an unknown key ({envelope.get('key_id')!r})")
+    payload = envelope.get("payload")
+    if not isinstance(payload, dict):
+        raise FeedRejected("no payload")
+    try:
+        signature = base64.b64decode(str(envelope.get("signature")), validate=True)
+        Ed25519PublicKey.from_public_bytes(base64.b64decode(public)).verify(signature, canonical(payload))
+    except (ValueError, InvalidSignature) as exc:
+        raise FeedRejected("signature does not verify") from exc
+    if payload.get("format") != FEED_FORMAT:
+        raise FeedRejected("unknown payload format")
+    serial = payload.get("serial")
+    if not isinstance(serial, int) or isinstance(serial, bool) or serial < 1:
+        raise FeedRejected("no serial")
+    if not isinstance(payload.get("announcements"), list):
+        raise FeedRejected("no announcement list")
+    return payload
+
+
+# ---- what this install is ------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class InstallFacts:
+    version: str
+    channel: str  # stable | beta
+    install_type: str  # docker | native | ha_addon | windows
+
+
+def _version_key(version: str) -> tuple:
+    """Sortable form that puts a release above its own betas (1.2.6 > 1.2.6b3)."""
+    from backend.app.api.routes.updates import parse_version
+
+    parsed = parse_version(version)
+    major, minor, patch, micro = (parsed + (0, 0, 0, 0))[:4]
+    is_prerelease = parsed[4] if len(parsed) > 4 else 0
+    prerelease_num = parsed[5] if len(parsed) > 5 else 0
+    return (major, minor, patch, micro, 1 - is_prerelease, prerelease_num)
+
+
+def _install_type() -> str:
+    from backend.app.api.routes import updates
+
+    if updates._is_windows_installer_install():
+        return "windows"
+    if updates._is_ha_addon():
+        return "ha_addon"
+    if updates._is_docker_environment():
+        return "docker"
+    return "native"
+
+
+async def install_facts(db: AsyncSession) -> InstallFacts:
+    beta_setting = (
+        await db.execute(select(Settings.value).where(Settings.key == "include_beta_updates"))
+    ).scalar_one_or_none()
+    # Beta testers are the installs that asked for betas, and the ones running one.
+    prerelease = bool(re.search(r"[a-zA-Z]", APP_VERSION.lstrip("v")))
+    beta = prerelease or (beta_setting or "").lower() == "true"
+    return InstallFacts(version=APP_VERSION, channel="beta" if beta else "stable", install_type=_install_type())
+
+
+def targets(entry: dict, facts: InstallFacts) -> bool:
+    target = entry.get("target") or {}
+    if not isinstance(target, dict):
+        return False
+    try:
+        here = _version_key(facts.version)
+        low, high = target.get("min_version"), target.get("max_version")
+        if low and here < _version_key(str(low)):
+            return False
+        if high and here > _version_key(str(high)):
+            return False
+    except (TypeError, ValueError):
+        return False
+    channels = target.get("channels") or []
+    if channels and facts.channel not in channels:
+        return False
+    install_types = target.get("install_types") or []
+    return not (install_types and facts.install_type not in install_types)
+
+
+# ---- one entry, defensively ---------------------------------------------------------
+
+
+def link_allowed(url: str) -> bool:
+    try:
+        parts = urlsplit(url)
+        port = parts.port
+    except ValueError:
+        return False
+    host = (parts.hostname or "").lower()
+    if parts.scheme != "https" or not host or parts.username or parts.password:
+        return False
+    if port not in (None, 443):
+        return False
+    return any(host == allowed or host.endswith("." + allowed) for allowed in LINK_HOSTS)
+
+
+def _parse_time(value: object) -> datetime | None:
+    if not isinstance(value, str) or not value:
+        return None
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+    except ValueError:
+        return None
+    if parsed.tzinfo is not None:
+        parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parsed
+
+
+def _clean_texts(texts: object) -> dict[str, dict[str, str]] | None:
+    """Plain strings, cut to length. None when there is no usable English text."""
+    if not isinstance(texts, dict):
+        return None
+    cleaned: dict[str, dict[str, str]] = {}
+    for lang, text in texts.items():
+        if not isinstance(lang, str) or len(lang) > 10 or not isinstance(text, dict):
+            continue
+        title, body = text.get("title"), text.get("body")
+        if not isinstance(title, str) or not isinstance(body, str) or not title.strip() or not body.strip():
+            continue
+        entry = {"title": title.strip()[:MAX_TITLE], "body": body.strip()[:MAX_BODY]}
+        label = text.get("link_label")
+        if isinstance(label, str) and label.strip():
+            entry["link_label"] = label.strip()[:MAX_LINK_LABEL]
+        cleaned[lang] = entry
+    return cleaned if "en" in cleaned else None
+
+
+@dataclass
+class Entry:
+    public_id: str
+    level: str
+    texts: dict[str, dict[str, str]]
+    link_url: str | None
+    published_at: datetime | None
+    expires_at: datetime | None
+
+
+def parse_entry(raw: object) -> Entry | None:
+    """One feed entry, or None if it can't be shown safely. Never raises."""
+    if not isinstance(raw, dict):
+        return None
+    public_id = raw.get("id")
+    if not isinstance(public_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", public_id):
+        return None
+    level = raw.get("level") if raw.get("level") in LEVELS else "info"
+    texts = _clean_texts(raw.get("texts"))
+    if texts is None:
+        return None
+    link = raw.get("link_url")
+    link = link if isinstance(link, str) and link_allowed(link) else None
+    return Entry(
+        public_id=public_id,
+        level=level,
+        texts=texts,
+        link_url=link,
+        published_at=_parse_time(raw.get("published_at")),
+        expires_at=_parse_time(raw.get("expires_at")),
+    )
+
+
+# ---- store ---------------------------------------------------------------------------
+
+
+async def _get(db: AsyncSession, key: str) -> str | None:
+    return (await db.execute(select(Settings.value).where(Settings.key == key))).scalar_one_or_none()
+
+
+async def _set(db: AsyncSession, key: str, value: str) -> None:
+    from backend.app.core.db_dialect import upsert_setting
+
+    await upsert_setting(db, Settings, key, value)
+
+
+async def is_enabled(db: AsyncSession) -> bool:
+    return (await _get(db, ENABLED_KEY) or "true").lower() != "false"
+
+
+async def apply_payload(db: AsyncSession, payload: dict, facts: InstallFacts) -> int:
+    """Replace the stored announcements with the ones in ``payload`` that apply here.
+
+    Refuses (FeedRejected) a serial below the highest already accepted. The same
+    serial again is the same feed fetched twice, and is fine. Returns how many are
+    stored. The caller commits.
+    """
+    serial = payload["serial"]
+    seen = int(await _get(db, SERIAL_KEY) or 0)
+    if serial < seen:
+        raise FeedRejected(f"serial {serial} is older than {seen}, already accepted")
+
+    wanted: dict[str, Entry] = {}
+    for raw in payload["announcements"]:
+        entry = parse_entry(raw)
+        if entry is not None and targets(raw, facts):
+            wanted[entry.public_id] = entry
+
+    existing = {a.public_id: a for a in (await db.execute(select(Announcement))).scalars().all()}
+    gone = [a.id for pid, a in existing.items() if pid not in wanted]
+    if gone:
+        # Read markers first: SQLite enforces no foreign keys unless asked to, so
+        # ON DELETE CASCADE alone would leave them behind.
+        await db.execute(delete(AnnouncementRead).where(AnnouncementRead.announcement_id.in_(gone)))
+        await db.execute(delete(Announcement).where(Announcement.id.in_(gone)))
+    for pid, entry in wanted.items():
+        row = existing.get(pid) or Announcement(public_id=pid)
+        row.level = entry.level
+        row.texts = json.dumps(entry.texts, ensure_ascii=False)
+        row.link_url = entry.link_url
+        row.published_at = entry.published_at
+        row.expires_at = entry.expires_at
+        if pid not in existing:
+            db.add(row)
+    await _set(db, SERIAL_KEY, str(max(serial, seen)))
+    return len(wanted)
+
+
+async def _download() -> bytes:
+    # No version and no install identity in the request: the generic agent says
+    # what is asking, nothing about which install.
+    headers = {"User-Agent": "Bambuddy-Announcements", "Cache-Control": "no-cache"}
+    async with (
+        httpx.AsyncClient(timeout=15, follow_redirects=False) as client,
+        client.stream("GET", FEED_URL, headers=headers) as response,
+    ):
+        if response.status_code != 200:
+            raise FeedRejected(f"HTTP {response.status_code}")
+        chunks: list[bytes] = []
+        size = 0
+        async for chunk in response.aiter_bytes():
+            size += len(chunk)
+            if size > MAX_FEED_BYTES:
+                raise FeedRejected("larger than the feed is allowed to be")
+            chunks.append(chunk)
+        return b"".join(chunks)
+
+
+async def refresh(db: AsyncSession) -> int | None:
+    """Fetch, verify and store. Returns how many apply here, or None if nothing changed.
+
+    Never raises for a network problem or a bad feed -- those are logged and the
+    last good list stays. A switched-off install fetches nothing.
+    """
+    if not await is_enabled(db):
+        return None
+    try:
+        content = await _download()
+        payload = verify_feed(content)
+        count = await apply_payload(db, payload, await install_facts(db))
+    except FeedRejected as exc:
+        await db.rollback()
+        logger.warning("Announcements feed ignored: %s", exc)
+        return None
+    except httpx.HTTPError as exc:
+        await db.rollback()
+        # Offline and air-gapped installs land here every time; not worth a warning.
+        logger.info("Announcements feed not fetched: %s", type(exc).__name__)
+        return None
+    await _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
+    await db.commit()
+    logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
+    return count
+
+
+# ---- read state ----------------------------------------------------------------------
+
+
+def _visible_filter(now: datetime):
+    return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now)
+
+
+async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
+    """Live announcements, newest first, each with whether this user has read it."""
+    now = datetime.now(timezone.utc).replace(tzinfo=None)
+    rows = (
+        (
+            await db.execute(
+                select(Announcement)
+                .where(_visible_filter(now))
+                .order_by(Announcement.published_at.desc(), Announcement.id.desc())
+            )
+        )
+        .scalars()
+        .all()
+    )
+    reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
+    read_ids = set((await db.execute(select(AnnouncementRead.announcement_id).where(reader))).scalars().all())
+    result = []
+    for a in rows:
+        try:
+            texts = json.loads(a.texts)
+        except (TypeError, ValueError):
+            continue
+        result.append(
+            {
+                "id": a.public_id,
+                "level": a.level,
+                "texts": texts,
+                "link_url": a.link_url,
+                "published_at": a.published_at.isoformat() + "Z" if a.published_at else None,
+                "expires_at": a.expires_at.isoformat() + "Z" if a.expires_at else None,
+                "read": a.id in read_ids,
+            }
+        )
+    return result
+
+
+async def mark_read(db: AsyncSession, public_id: str, user_id: int | None) -> bool:
+    """Record that this user read it. False if there is no such announcement."""
+    announcement = (
+        await db.execute(select(Announcement).where(Announcement.public_id == public_id))
+    ).scalar_one_or_none()
+    if announcement is None:
+        return False
+    reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
+    # Checked rather than left to the unique constraint: NULL user_ids never
+    # collide in one, so the auth-off row would be duplicated on every click.
+    already = (
+        await db.execute(select(AnnouncementRead.id).where(AnnouncementRead.announcement_id == announcement.id, reader))
+    ).first()
+    if already is None:
+        db.add(AnnouncementRead(announcement_id=announcement.id, user_id=user_id))
+    return True
+
+
+# ---- background loop -----------------------------------------------------------------
+
+_task: asyncio.Task | None = None
+
+
+async def _loop() -> None:
+    from backend.app.core.database import async_session
+
+    await asyncio.sleep(FIRST_FETCH_DELAY_SECONDS)
+    while True:
+        try:
+            async with async_session() as db:
+                await refresh(db)
+        except asyncio.CancelledError:
+            raise
+        except Exception:
+            logger.exception("Announcements refresh failed")
+        await asyncio.sleep(FETCH_INTERVAL_SECONDS + random.uniform(0, FETCH_JITTER_SECONDS))
+
+
+def start() -> None:
+    global _task
+    if _task is None:
+        _task = asyncio.create_task(_loop())
+
+
+def stop() -> None:
+    global _task
+    if _task is not None:
+        _task.cancel()
+        _task = None

+ 134 - 0
backend/tests/integration/test_announcements_api.py

@@ -0,0 +1,134 @@
+"""Who sees announcements, and per-user read state.
+
+Administrators see them; other signed-in users only when
+``announcements_all_users`` is on. With authentication off, the person running
+Bambuddy sees them. Anyone else gets an empty list, which hides the sidebar entry
+and the banner the same way "nothing published" does.
+"""
+
+import json
+import secrets
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.announcement import Announcement
+from backend.app.models.settings import Settings
+
+URL = "/api/v1/announcements"
+_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12)  # pragma: allowlist secret
+
+
+async def _seed(db, *public_ids: str, level: str = "important") -> None:
+    for pid in public_ids:
+        db.add(
+            Announcement(
+                public_id=pid,
+                level=level,
+                texts=json.dumps({"en": {"title": f"T {pid}", "body": "B"}, "de": {"title": "D", "body": "B"}}),
+            )
+        )
+    await db.commit()
+
+
+async def _setting(db, key: str, value: str) -> None:
+    db.add(Settings(key=key, value=value))
+    await db.commit()
+
+
+async def _admin(client: AsyncClient, username: str) -> dict:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW})
+    assert login.status_code == 200, login.text
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _user(client: AsyncClient, admin: dict, username: str) -> dict:
+    grp = await client.post(
+        "/api/v1/groups/", headers=admin, json={"name": f"ann_{username}", "permissions": ["printers:read"]}
+    )
+    assert grp.status_code == 201, grp.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": username, "password": _FIXTURE_PW, "role": "user", "group_ids": [grp.json()["id"]]},
+    )
+    assert created.status_code == 201, created.text
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+@pytest.mark.integration
+class TestAuthOff:
+    @pytest.mark.asyncio
+    async def test_listed_with_every_language_and_unread(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        r = await async_client.get(URL)
+        assert r.status_code == 200
+        [item] = r.json()
+        assert item["id"] == "a1" and item["level"] == "important" and item["read"] is False
+        assert set(item["texts"]) == {"en", "de"}
+
+    @pytest.mark.asyncio
+    async def test_read_sticks(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        assert (await async_client.post(f"{URL}/a1/read")).status_code == 204
+        assert (await async_client.get(URL)).json()[0]["read"] is True
+
+    @pytest.mark.asyncio
+    async def test_unknown_id_is_404(self, async_client: AsyncClient):
+        assert (await async_client.post(f"{URL}/nope/read")).status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_switched_off_shows_nothing(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        await _setting(db_session, "announcements_enabled", "false")
+        assert (await async_client.get(URL)).json() == []
+        assert (await async_client.post(f"{URL}/a1/read")).status_code == 404
+
+
+@pytest.mark.integration
+class TestAuthOn:
+    @pytest.mark.asyncio
+    async def test_signed_out_is_401(self, async_client: AsyncClient, db_session):
+        await _admin(async_client, "annadmin0")
+        assert (await async_client.get(URL)).status_code == 401
+
+    @pytest.mark.asyncio
+    async def test_admin_sees_them_a_user_does_not(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        admin = await _admin(async_client, "annadmin1")
+        user = await _user(async_client, admin, "annuser1")
+        assert [i["id"] for i in (await async_client.get(URL, headers=admin)).json()] == ["a1"]
+        assert (await async_client.get(URL, headers=user)).json() == []
+        assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_all_users_setting_shows_them_with_separate_read_state(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        admin = await _admin(async_client, "annadmin2")
+        user = await _user(async_client, admin, "annuser2")
+        await _setting(db_session, "announcements_all_users", "true")
+
+        assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 204
+        assert (await async_client.get(URL, headers=user)).json()[0]["read"] is True
+        assert (await async_client.get(URL, headers=admin)).json()[0]["read"] is False
+
+
+@pytest.mark.integration
+class TestSettings:
+    @pytest.mark.asyncio
+    async def test_both_switches_round_trip(self, async_client: AsyncClient):
+        defaults = (await async_client.get("/api/v1/settings/")).json()
+        assert defaults["announcements_enabled"] is True
+        assert defaults["announcements_all_users"] is False
+        r = await async_client.put(
+            "/api/v1/settings/", json={"announcements_enabled": False, "announcements_all_users": True}
+        )
+        assert r.status_code == 200, r.text
+        after = (await async_client.get("/api/v1/settings/")).json()
+        assert after["announcements_enabled"] is False
+        assert after["announcements_all_users"] is True

+ 307 - 0
backend/tests/unit/test_announcements_service.py

@@ -0,0 +1,307 @@
+"""Announcements: verifying the signed feed, and keeping only what applies here.
+
+The feed is signed by the maintainers' registrar. These tests sign their own
+feeds with a throwaway key passed in as the trusted one, and check the rules an
+install applies: a bad signature, an unknown key or an older serial changes
+nothing; a message that does not target this install is not stored; a withdrawn
+one disappears with its read markers.
+"""
+
+import base64
+import hashlib
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from sqlalchemy import select
+
+from backend.app.models.announcement import Announcement, AnnouncementRead
+from backend.app.models.settings import Settings
+from backend.app.services import announcements as svc
+from backend.app.services.announcements import FeedRejected, InstallFacts
+
+KEY = Ed25519PrivateKey.generate()
+_PUB = KEY.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+KEY_ID = hashlib.sha256(_PUB).hexdigest()[:16]
+TRUSTED = {KEY_ID: base64.b64encode(_PUB).decode()}
+
+STABLE_DOCKER = InstallFacts(version="1.2.6", channel="stable", install_type="docker")
+
+
+def entry(public_id="a1", level="info", target=None, **extra):
+    raw = {
+        "id": public_id,
+        "level": level,
+        "published_at": "2026-10-01T12:00:00Z",
+        "expires_at": None,
+        "texts": {"en": {"title": f"Title {public_id}", "body": "Body"}},
+        "link_url": None,
+        "target": target or {"min_version": None, "max_version": None, "channels": [], "install_types": []},
+    }
+    raw.update(extra)
+    return raw
+
+
+def payload(*entries, serial=1):
+    return {"format": 1, "serial": serial, "published_at": "2026-10-01T12:00:00Z", "announcements": list(entries)}
+
+
+def signed(p: dict, key: Ed25519PrivateKey = KEY, key_id: str = KEY_ID) -> bytes:
+    """The file exactly as the registrar writes it: pretty, payload as an object."""
+    sig = key.sign(svc.canonical(p))
+    envelope = {"format": 1, "key_id": key_id, "signature": base64.b64encode(sig).decode(), "payload": p}
+    return json.dumps(envelope, indent=2, ensure_ascii=False).encode()
+
+
+class TestVerify:
+    def test_a_correctly_signed_feed_verifies(self):
+        p = payload(entry(texts={"en": {"title": "Grüße — 你好", "body": "x"}}))
+        assert svc.verify_feed(signed(p), TRUSTED) == p
+
+    def test_tampered_text_is_refused(self):
+        content = signed(payload(entry()))
+        with pytest.raises(FeedRejected, match="signature"):
+            svc.verify_feed(content.replace(b"Title a1", b"Title b1"), TRUSTED)
+
+    def test_another_key_is_refused(self):
+        stranger = Ed25519PrivateKey.generate()
+        with pytest.raises(FeedRejected, match="signature"):
+            svc.verify_feed(signed(payload(entry()), key=stranger), TRUSTED)
+
+    def test_unknown_key_id_is_refused(self):
+        with pytest.raises(FeedRejected, match="unknown key"):
+            svc.verify_feed(signed(payload(entry()), key_id="0000000000000000"), TRUSTED)
+
+    @pytest.mark.parametrize("content", [b"", b"not json", b"[]", b'{"format": 2}'])
+    def test_garbage_is_refused(self, content):
+        with pytest.raises(FeedRejected):
+            svc.verify_feed(content, TRUSTED)
+
+    @pytest.mark.parametrize("serial", [None, 0, "3", True])
+    def test_a_payload_without_a_usable_serial_is_refused(self, serial):
+        p = payload(entry())
+        p["serial"] = serial
+        with pytest.raises(FeedRejected, match="serial"):
+            svc.verify_feed(signed(p), TRUSTED)
+
+    def test_the_built_in_key_is_well_formed(self):
+        for key_id, public in svc.TRUSTED_KEYS.items():
+            raw = base64.b64decode(public)
+            assert len(raw) == 32
+            assert hashlib.sha256(raw).hexdigest()[:16] == key_id
+
+
+class TestEntries:
+    @pytest.mark.parametrize("public_id", [None, "", "a b", "x" * 65, 7, "../x"])
+    def test_unusable_ids_are_dropped(self, public_id):
+        assert svc.parse_entry(entry(public_id=public_id)) is None
+
+    def test_no_english_text_is_dropped(self):
+        assert svc.parse_entry(entry(texts={"de": {"title": "Hallo", "body": "x"}})) is None
+
+    def test_texts_are_cut_to_length_and_bad_languages_skipped(self):
+        e = svc.parse_entry(
+            entry(
+                texts={
+                    "en": {"title": "t" * 500, "body": "b" * 5000, "link_label": "l" * 100},
+                    "de": {"title": "", "body": "x"},
+                    "fr": "not a dict",
+                }
+            )
+        )
+        assert set(e.texts) == {"en"}
+        assert len(e.texts["en"]["title"]) == svc.MAX_TITLE
+        assert len(e.texts["en"]["body"]) == svc.MAX_BODY
+        assert len(e.texts["en"]["link_label"]) == svc.MAX_LINK_LABEL
+
+    def test_unknown_level_is_info(self):
+        assert svc.parse_entry(entry(level="apocalyptic")).level == "info"
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://bambuddy.cool/",
+            "https://evil.example/",
+            "https://bambuddy.cool.evil.example/",
+            "https://user@github.com/",
+            "https://github.com:8443/",
+            "javascript:alert(1)",
+        ],
+    )
+    def test_links_off_the_allowlist_are_dropped_not_the_message(self, url):
+        e = svc.parse_entry(entry(link_url=url))
+        assert e is not None and e.link_url is None
+
+    def test_allowed_link_is_kept(self):
+        assert svc.parse_entry(entry(link_url="https://wiki.bambuddy.cool/x/")).link_url == (
+            "https://wiki.bambuddy.cool/x/"
+        )
+
+    def test_times_are_naive_utc(self):
+        e = svc.parse_entry(entry(expires_at="2026-11-01T02:00:00+02:00"))
+        assert e.expires_at == datetime(2026, 11, 1, 0, 0)
+
+
+class TestTargeting:
+    @pytest.mark.parametrize(
+        "version, low, high, shown",
+        [
+            ("1.2.6", None, None, True),
+            ("1.2.6", "1.2.6", None, True),
+            ("1.2.6", "1.2.7", None, False),
+            ("1.2.6", None, "1.2.5", False),
+            ("1.2.6b1", "1.2.6", None, False),  # a beta is older than its release
+            ("1.2.6b1", None, "1.2.6", True),
+            ("1.2.6b3", "1.2.6b2", "1.2.6b3", True),
+        ],
+    )
+    def test_version_range(self, version, low, high, shown):
+        facts = InstallFacts(version=version, channel="stable", install_type="docker")
+        target = {"min_version": low, "max_version": high}
+        assert svc.targets({"target": target}, facts) is shown
+
+    def test_channel(self):
+        beta_only = {"target": {"channels": ["beta"]}}
+        assert not svc.targets(beta_only, STABLE_DOCKER)
+        assert svc.targets(beta_only, InstallFacts("1.2.6", "beta", "docker"))
+
+    def test_install_type(self):
+        windows_only = {"target": {"install_types": ["windows"]}}
+        assert not svc.targets(windows_only, STABLE_DOCKER)
+        assert svc.targets(windows_only, InstallFacts("1.2.6", "stable", "windows"))
+
+    def test_a_malformed_target_shows_nothing(self):
+        assert not svc.targets({"target": "everyone"}, STABLE_DOCKER)
+
+
+async def _stored(db) -> list[str]:
+    return sorted((await db.execute(select(Announcement.public_id))).scalars().all())
+
+
+async def _serial(db) -> str | None:
+    return (await db.execute(select(Settings.value).where(Settings.key == svc.SERIAL_KEY))).scalar_one_or_none()
+
+
+class TestApply:
+    @pytest.mark.asyncio
+    async def test_stores_only_what_targets_this_install(self, db_session):
+        p = payload(
+            entry("everyone"),
+            entry("beta", target={"channels": ["beta"]}),
+            entry("windows", target={"install_types": ["windows"]}),
+            entry("broken", texts={}),
+        )
+        assert await svc.apply_payload(db_session, p, STABLE_DOCKER) == 1
+        await db_session.commit()
+        assert await _stored(db_session) == ["everyone"]
+        assert await _serial(db_session) == "1"
+
+    @pytest.mark.asyncio
+    async def test_withdrawn_upstream_goes_with_its_read_markers(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("keep"), entry("drop")), STABLE_DOCKER)
+        await db_session.commit()
+        assert await svc.mark_read(db_session, "drop", None)
+        await db_session.commit()
+
+        await svc.apply_payload(db_session, payload(entry("keep"), serial=2), STABLE_DOCKER)
+        await db_session.commit()
+        assert await _stored(db_session) == ["keep"]
+        assert (await db_session.execute(select(AnnouncementRead))).scalars().all() == []
+
+    @pytest.mark.asyncio
+    async def test_an_edit_keeps_the_read_state(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER)
+        await db_session.commit()
+        await svc.mark_read(db_session, "a1", None)
+        await db_session.commit()
+        edited = entry("a1", texts={"en": {"title": "Fixed typo", "body": "Body"}})
+        await svc.apply_payload(db_session, payload(edited, serial=2), STABLE_DOCKER)
+        await db_session.commit()
+        [item] = await svc.list_for(db_session, None)
+        assert item["texts"]["en"]["title"] == "Fixed typo"
+        assert item["read"] is True
+
+    @pytest.mark.asyncio
+    async def test_an_older_serial_is_refused_and_changes_nothing(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("new"), serial=5), STABLE_DOCKER)
+        await db_session.commit()
+        with pytest.raises(FeedRejected, match="older"):
+            await svc.apply_payload(db_session, payload(entry("old"), serial=4), STABLE_DOCKER)
+        await db_session.rollback()
+        assert await _stored(db_session) == ["new"]
+        assert await _serial(db_session) == "5"
+
+    @pytest.mark.asyncio
+    async def test_the_same_serial_again_is_fine(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER)
+        await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER)
+        await db_session.commit()
+        assert await _stored(db_session) == ["a1"]
+
+
+class TestReadState:
+    @pytest.mark.asyncio
+    async def test_expired_ones_are_not_listed(self, db_session):
+        past = (datetime.now(timezone.utc) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
+        future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
+        p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever"))
+        await svc.apply_payload(db_session, p, STABLE_DOCKER)
+        await db_session.commit()
+        assert sorted(i["id"] for i in await svc.list_for(db_session, None)) == ["forever", "live"]
+
+    @pytest.mark.asyncio
+    async def test_reading_twice_with_auth_off_records_once(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER)
+        await db_session.commit()
+        assert await svc.mark_read(db_session, "a1", None)
+        await db_session.commit()
+        assert await svc.mark_read(db_session, "a1", None)
+        await db_session.commit()
+        assert len((await db_session.execute(select(AnnouncementRead))).scalars().all()) == 1
+
+    @pytest.mark.asyncio
+    async def test_unknown_id_is_not_marked(self, db_session):
+        assert not await svc.mark_read(db_session, "nope", None)
+
+
+class TestRefresh:
+    @pytest.mark.asyncio
+    async def test_switched_off_fetches_nothing(self, db_session):
+        db_session.add(Settings(key=svc.ENABLED_KEY, value="false"))
+        await db_session.commit()
+        with patch.object(svc, "_download", AsyncMock()) as download:
+            assert await svc.refresh(db_session) is None
+        download.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_good_feed_is_stored(self, db_session):
+        with (
+            patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))),
+            patch.object(svc, "TRUSTED_KEYS", TRUSTED),
+            patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
+        ):
+            assert await svc.refresh(db_session) == 1
+        assert await _stored(db_session) == ["a1"]
+
+    @pytest.mark.asyncio
+    async def test_a_bad_feed_keeps_the_last_good_list(self, db_session):
+        with (
+            patch.object(svc, "TRUSTED_KEYS", TRUSTED),
+            patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
+        ):
+            with patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))):
+                await svc.refresh(db_session)
+            forged = signed(payload(serial=9), key=Ed25519PrivateKey.generate())
+            with patch.object(svc, "_download", AsyncMock(return_value=forged)):
+                assert await svc.refresh(db_session) is None
+        assert await _stored(db_session) == ["a1"]
+
+    @pytest.mark.asyncio
+    async def test_offline_is_quiet_and_harmless(self, db_session):
+        import httpx
+
+        with patch.object(svc, "_download", AsyncMock(side_effect=httpx.ConnectError("offline"))):
+            assert await svc.refresh(db_session) is None

+ 159 - 0
frontend/src/__tests__/components/Announcements.test.tsx

@@ -0,0 +1,159 @@
+/**
+ * Announcements from the Bambuddy maintainers: the sidebar entry above System,
+ * the slide-over list, and the banner for unread important/critical messages.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AnnouncementsPanel } from '../../components/AnnouncementsPanel';
+import { AnnouncementBanner } from '../../components/AnnouncementBanner';
+import { Layout } from '../../components/Layout';
+import type { Announcement } from '../../api/client';
+
+function announcement(overrides: Partial<Announcement> = {}): Announcement {
+  return {
+    id: 'a1',
+    level: 'info',
+    texts: { en: { title: 'Hello installs', body: 'Line one\nLine two' } },
+    link_url: null,
+    published_at: '2026-10-01T12:00:00Z',
+    expires_at: null,
+    read: false,
+    ...overrides,
+  };
+}
+
+describe('AnnouncementsPanel', () => {
+  it('marks what was unread as read on opening, and keeps a New chip on it', () => {
+    const markRead = vi.fn();
+    render(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        markRead={markRead}
+        announcements={[announcement(), announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'x' } } })]}
+      />
+    );
+    expect(markRead).toHaveBeenCalledTimes(1);
+    expect(markRead).toHaveBeenCalledWith('a1');
+    const items = screen.getAllByRole('listitem');
+    expect(within(items[0]).getByText('New')).toBeInTheDocument();
+    expect(within(items[1]).queryByText('New')).not.toBeInTheDocument();
+  });
+
+  it('renders the body as plain text, never as HTML', () => {
+    render(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        markRead={() => {}}
+        announcements={[announcement({ texts: { en: { title: 'T', body: '<img src=x onerror=alert(1)>' } } })]}
+      />
+    );
+    expect(screen.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
+    expect(document.querySelector('img[src="x"]')).toBeNull();
+  });
+
+  it('links only to allowed hosts', () => {
+    render(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        markRead={() => {}}
+        announcements={[
+          announcement({ id: 'ok', link_url: 'https://wiki.bambuddy.cool/x/', texts: { en: { title: 'A', body: 'b', link_label: 'Details' } } }),
+          announcement({ id: 'bad', link_url: 'https://evil.example/x', texts: { en: { title: 'B', body: 'b', link_label: 'Phish' } } }),
+        ]}
+      />
+    );
+    const link = screen.getByRole('link', { name: /Details/ });
+    expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
+    expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+    expect(screen.queryByRole('link', { name: /Phish/ })).not.toBeInTheDocument();
+  });
+
+  it('closes on Escape', () => {
+    const onClose = vi.fn();
+    render(<AnnouncementsPanel open onClose={onClose} markRead={() => {}} announcements={[announcement()]} />);
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalled();
+  });
+});
+
+describe('AnnouncementBanner', () => {
+  it('shows the first item, offers the rest, and Got it marks it read', () => {
+    const markRead = vi.fn();
+    const onOpen = vi.fn();
+    render(
+      <AnnouncementBanner
+        items={[
+          announcement({ id: 'crit', level: 'critical', texts: { en: { title: 'Update now', body: 'b' } } }),
+          announcement({ id: 'imp', level: 'important' }),
+        ]}
+        onOpen={onOpen}
+        markRead={markRead}
+      />
+    );
+    expect(screen.getByRole('alert')).toHaveTextContent('Update now');
+    fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
+    expect(onOpen).toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
+    expect(markRead).toHaveBeenCalledWith('crit');
+  });
+
+  it('renders nothing without items', () => {
+    render(<AnnouncementBanner items={[]} onOpen={() => {}} markRead={() => {}} />);
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+    expect(screen.queryByRole('status')).not.toBeInTheDocument();
+  });
+});
+
+describe('Layout with announcements', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
+      http.get('/api/v1/settings/ui-flags', () =>
+        HttpResponse.json({ check_updates: false, billing_enabled: false, user_notifications_enabled: true, currency: 'EUR' })
+      )
+    );
+  });
+
+  it('shows no entry and no banner when there is nothing', async () => {
+    render(<Layout />);
+    await waitFor(() => expect(screen.getAllByTitle(/System/).length).toBeGreaterThan(0));
+    expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
+  });
+
+  it('shows the entry with an unread count, the banner for important, and opens the list', async () => {
+    let reads: string[] = [];
+    server.use(
+      http.get('/api/v1/announcements', () =>
+        HttpResponse.json([
+          announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }),
+          announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }),
+        ])
+      ),
+      http.post('/api/v1/announcements/:id/read', ({ params }) => {
+        reads = [...reads, String(params.id)];
+        return new HttpResponse(null, { status: 204 });
+      })
+    );
+    render(<Layout />);
+
+    const entry = await screen.findByRole('button', { name: /Announcements/ });
+    expect(within(entry).getByText('2')).toBeInTheDocument();
+    // Only the important one earns a banner.
+    expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
+    expect(screen.queryByText('Testers wanted')).not.toBeInTheDocument();
+
+    fireEvent.click(entry);
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument();
+    await waitFor(() => expect(reads.sort()).toEqual(['imp', 'inf']));
+    // Read now: the banner is gone.
+    await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
+  });
+});

+ 45 - 0
frontend/src/__tests__/hooks/useAnnouncements.test.ts

@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest';
+import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnnouncements';
+import type { Announcement } from '../../api/client';
+
+const a = (texts: Announcement['texts']): Announcement => ({
+  id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, read: false,
+});
+
+describe('announcementText', () => {
+  const texts = {
+    en: { title: 'Hello', body: 'b' },
+    de: { title: 'Hallo', body: 'b' },
+    'pt-BR': { title: 'Olá', body: 'b' },
+    'zh-CN': { title: '你好', body: 'b' },
+  };
+
+  it.each([
+    ['de', 'Hallo'],
+    ['pt-BR', 'Olá'],
+    ['pt-br', 'Olá'],
+    ['pt', 'Olá'],
+    ['zh-TW', '你好'],
+    ['fr', 'Hello'],
+    ['en-US', 'Hello'],
+  ])('%s -> %s', (lang, title) => {
+    expect(announcementText(a(texts), lang).title).toBe(title);
+  });
+});
+
+describe('isAllowedAnnouncementLink', () => {
+  it.each([
+    ['https://wiki.bambuddy.cool/x', true],
+    ['https://bambuddy.cool', true],
+    ['https://github.com/maziggy/bambuddy', true],
+    ['http://github.com/', false],
+    ['https://github.com.evil.example/', false],
+    ['https://user:pw@github.com/', false],
+    ['https://github.com:8443/', false],
+    ['javascript:alert(1)', false],
+    ['not a url', false],
+    [null, false],
+  ])('%s -> %s', (url, allowed) => {
+    expect(isAllowedAnnouncementLink(url)).toBe(allowed);
+  });
+});

+ 5 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -383,6 +383,11 @@ export const handlers = [
   // Version / Health
   // Version / Health
   // ========================================================================
   // ========================================================================
 
 
+  // Announcements from the maintainers: none by default.
+  http.get('/api/v1/announcements', () => {
+    return HttpResponse.json([]);
+  }),
+
   http.get('/api/v1/version', () => {
   http.get('/api/v1/version', () => {
     return HttpResponse.json({
     return HttpResponse.json({
       version: '0.1.5',
       version: '0.1.5',

+ 29 - 0
frontend/src/api/client.ts

@@ -1383,6 +1383,9 @@ export interface AppSettings {
   check_updates: boolean;
   check_updates: boolean;
   check_printer_firmware: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
   include_beta_updates: boolean;
+  // Announcements from the Bambuddy maintainers (a signed file on GitHub).
+  announcements_enabled?: boolean;
+  announcements_all_users?: boolean;
   // #1589: false hides the local username/password form on the login page;
   // #1589: false hides the local username/password form on the login page;
   // BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
   // BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
   // true so the env-var recovery path is visible to the SPA.
   // true so the env-var recovery path is visible to the SPA.
@@ -9196,6 +9199,32 @@ export const bugReportApi = {
     }),
     }),
 };
 };
 
 
+export type AnnouncementLevel = 'info' | 'important' | 'critical';
+
+export interface AnnouncementText {
+  title: string;
+  body: string;
+  link_label?: string;
+}
+
+// One message from the Bambuddy maintainers. `texts` holds every language the
+// message was written in; English is always there.
+export interface Announcement {
+  id: string;
+  level: AnnouncementLevel;
+  texts: Record<string, AnnouncementText>;
+  link_url: string | null;
+  published_at: string | null;
+  expires_at: string | null;
+  read: boolean;
+}
+
+export const announcementsApi = {
+  list: () => request<Announcement[]>('/announcements'),
+  markRead: (id: string) =>
+    request<void>(`/announcements/${encodeURIComponent(id)}/read`, { method: 'POST' }),
+};
+
 export interface SponsorPromptCheckResponse {
 export interface SponsorPromptCheckResponse {
   show: boolean;
   show: boolean;
   milestone?: string;
   milestone?: string;

+ 63 - 0
frontend/src/components/AnnouncementBanner.tsx

@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, Megaphone } from 'lucide-react';
+import type { Announcement } from '../api/client';
+import { announcementText } from '../hooks/useAnnouncements';
+
+interface AnnouncementBannerProps {
+  // Unread important/critical announcements, most severe first.
+  items: Announcement[];
+  onOpen: () => void;
+  markRead: (id: string) => void;
+}
+
+/**
+ * The strip above the page for an unread important or critical announcement.
+ *
+ * One at a time, the most severe; "+N more" opens the panel. "Got it" marks it
+ * read on the server, so it stays gone on every browser this user signs in on.
+ * Info-level messages never get a banner -- only the sidebar dot.
+ */
+export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBannerProps) {
+  const { t, i18n } = useTranslation();
+  const first = items[0];
+  if (!first) return null;
+  const text = announcementText(first, i18n.language);
+  const critical = first.level === 'critical';
+  const Icon = critical ? AlertTriangle : Megaphone;
+
+  return (
+    <div
+      role={critical ? 'alert' : 'status'}
+      className={`px-4 py-2 flex items-center gap-3 border-b text-sm ${
+        critical
+          ? 'bg-red-100 dark:bg-red-500/20 border-red-300 dark:border-red-500/30'
+          : 'bg-amber-100 dark:bg-amber-500/20 border-amber-300 dark:border-amber-500/30'
+      }`}
+    >
+      <Icon className={`w-4 h-4 flex-shrink-0 ${critical ? 'text-red-500' : 'text-amber-500'}`} />
+      <div className="flex items-center gap-x-2 gap-y-1 flex-wrap min-w-0">
+        <span className={`font-medium ${critical ? 'text-red-800 dark:text-red-200' : 'text-amber-800 dark:text-amber-200'}`}>
+          {text.title}
+        </span>
+        <button
+          onClick={onOpen}
+          className={`font-medium underline ${
+            critical
+              ? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'
+              : 'text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300'
+          }`}
+        >
+          {items.length > 1
+            ? t('announcements.readMoreCount', { count: items.length - 1 })
+            : t('announcements.readMore')}
+        </button>
+      </div>
+      <button
+        onClick={() => markRead(first.id)}
+        className="ml-auto flex-shrink-0 px-2 py-1 rounded font-medium text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+      >
+        {t('announcements.gotIt')}
+      </button>
+    </div>
+  );
+}

+ 129 - 0
frontend/src/components/AnnouncementsPanel.tsx

@@ -0,0 +1,129 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ExternalLink, Megaphone, X } from 'lucide-react';
+import type { Announcement, AnnouncementLevel } from '../api/client';
+import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
+import { formatDateOnly } from '../utils/date';
+
+const LEVEL_CHIP: Record<AnnouncementLevel, string> = {
+  info: 'bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-300',
+  important: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300',
+  critical: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300',
+};
+
+interface AnnouncementsPanelProps {
+  open: boolean;
+  onClose: () => void;
+  announcements: Announcement[];
+  markRead: (id: string) => void;
+}
+
+/**
+ * Slide-over list of announcements from the Bambuddy maintainers.
+ *
+ * Opening it marks everything in it read -- the dot and the banner go -- but the
+ * ones that were unread keep a "New" chip until it closes, so the reader can
+ * still tell what they came for.
+ */
+export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
+  const { t, i18n } = useTranslation();
+  const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
+  const closeRef = useRef<HTMLButtonElement>(null);
+  // The list at the moment of opening; later refetches don't re-mark anything.
+  const latest = useRef({ announcements, markRead });
+  latest.current = { announcements, markRead };
+
+  useEffect(() => {
+    if (!open) return;
+    const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
+    setNewIds(new Set(unreadIds));
+    unreadIds.forEach((id) => latest.current.markRead(id));
+    closeRef.current?.focus();
+  }, [open]);
+
+  useEffect(() => {
+    if (!open) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKey);
+    return () => window.removeEventListener('keydown', onKey);
+  }, [open, onClose]);
+
+  if (!open) return null;
+
+  return (
+    <>
+      <div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
+      <aside
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="announcements-title"
+        className="fixed inset-y-0 right-0 z-[61] w-full max-w-md bg-bambu-dark-secondary border-l border-bambu-dark-tertiary flex flex-col shadow-2xl"
+      >
+        <header className="flex items-center gap-3 px-5 py-4 border-b border-bambu-dark-tertiary">
+          <Megaphone className="w-5 h-5 text-bambu-green" />
+          <h2 id="announcements-title" className="text-lg font-semibold text-white">
+            {t('announcements.title')}
+          </h2>
+          <button
+            ref={closeRef}
+            onClick={onClose}
+            className="ml-auto p-2 -mr-2 rounded-lg text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+            aria-label={t('common.close')}
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </header>
+
+        <div className="flex-1 overflow-y-auto">
+          {announcements.length === 0 ? (
+            <p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
+          ) : (
+            <ul className="divide-y divide-bambu-dark-tertiary">
+              {announcements.map((a) => {
+                const text = announcementText(a, i18n.language);
+                const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
+                return (
+                  <li key={a.id} className="px-5 py-4 space-y-2">
+                    <div className="flex items-center gap-2 flex-wrap text-xs">
+                      <span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
+                        {t(`announcements.level.${a.level}`)}
+                      </span>
+                      {newIds.has(a.id) && (
+                        <span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
+                          {t('announcements.new')}
+                        </span>
+                      )}
+                      {a.published_at && (
+                        <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
+                      )}
+                    </div>
+                    <h3 className="text-white font-semibold text-balance">{text.title}</h3>
+                    <p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
+                    {link && (
+                      <a
+                        href={link}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
+                      >
+                        {text.link_label || t('announcements.readMore')}
+                        <ExternalLink className="w-3.5 h-3.5" />
+                      </a>
+                    )}
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+        </div>
+
+        <footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
+          {t('announcements.source')}
+        </footer>
+      </aside>
+    </>
+  );
+}

+ 59 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -21,6 +21,9 @@ import { Card, CardHeader, CardContent } from './Card';
 import { parseUTCDate } from '../utils/date';
 import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
 import { Button } from './Button';
 import { BugReportBubble } from './BugReportBubble';
 import { BugReportBubble } from './BugReportBubble';
+import { AnnouncementsPanel } from './AnnouncementsPanel';
+import { AnnouncementBanner } from './AnnouncementBanner';
+import { useAnnouncements } from '../hooks/useAnnouncements';
 import {
 import {
   getHiddenSidebarSystemItemIds,
   getHiddenSidebarSystemItemIds,
   getSidebarOrder,
   getSidebarOrder,
@@ -149,6 +152,17 @@ export function Layout() {
     staleTime: 5 * 60 * 1000, // 5 minutes
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
   });
 
 
+  // Announcements from the Bambuddy maintainers: the sidebar entry above System,
+  // the slide-over list, and the banner for unread important/critical ones.
+  const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
+    useAnnouncements();
+  const [announcementsOpen, setAnnouncementsOpen] = useState(false);
+  const openAnnouncements = useCallback(() => {
+    setMobileDrawerOpen(false);
+    setAnnouncementsOpen(true);
+  }, []);
+  const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
+
   // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
   // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
   useSponsorPrompt(uiFlags?.currency ?? 'USD');
   useSponsorPrompt(uiFlags?.currency ?? 'USD');
 
 
@@ -733,6 +747,26 @@ export function Layout() {
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
           {isSidebarCompact || sidebarExpanded ? (
           {isSidebarCompact || sidebarExpanded ? (
             <div className="flex flex-col gap-2 px-2">
             <div className="flex flex-col gap-2 px-2">
+              {/* Announcements: a row of its own above the icons, so it isn't one
+                  more glyph in a row people have stopped reading. Only while
+                  there is something to show. */}
+              {announcements.length > 0 && (
+                <button
+                  onClick={openAnnouncements}
+                  className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white text-sm"
+                >
+                  <Megaphone className="w-5 h-5 flex-shrink-0" />
+                  <span>{t('announcements.title')}</span>
+                  {unreadAnnouncements.length > 0 && (
+                    <span
+                      className="ml-auto min-w-[20px] h-5 px-1.5 flex items-center justify-center text-[11px] font-bold rounded-full bg-bambu-green text-white"
+                      aria-label={t('announcements.unread', { count: unreadAnnouncements.length })}
+                    >
+                      {unreadAnnouncements.length}
+                    </span>
+                  )}
+                </button>
+              )}
               {/* Top row: icons */}
               {/* Top row: icons */}
               <div className="flex items-center justify-center gap-1 flex-wrap">
               <div className="flex items-center justify-center gap-1 flex-wrap">
                 {hasSwitchbarPlugs && (
                 {hasSwitchbarPlugs && (
@@ -856,6 +890,23 @@ export function Layout() {
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
+              {announcements.length > 0 && (
+                <button
+                  onClick={openAnnouncements}
+                  className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                  title={t('announcements.title')}
+                  aria-label={
+                    unreadAnnouncements.length > 0
+                      ? t('announcements.unread', { count: unreadAnnouncements.length })
+                      : t('announcements.title')
+                  }
+                >
+                  <Megaphone className="w-5 h-5" />
+                  {unreadAnnouncements.length > 0 && (
+                    <span className="absolute top-1 right-1 w-2.5 h-2.5 rounded-full bg-bambu-green ring-2 ring-bambu-dark-secondary" />
+                  )}
+                </button>
+              )}
               {hasPermission('system:read') ? (
               {hasPermission('system:read') ? (
                 <NavLink
                 <NavLink
                   to="/system"
                   to="/system"
@@ -979,6 +1030,7 @@ export function Layout() {
             </div>
             </div>
           </div>
           </div>
         )}
         )}
+        <AnnouncementBanner items={bannerItems} onOpen={openAnnouncements} markRead={markAnnouncementRead} />
         {/* Persistent update banner */}
         {/* Persistent update banner */}
         {showUpdateBanner && (
         {showUpdateBanner && (
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
@@ -1008,6 +1060,12 @@ export function Layout() {
         )}
         )}
         <Outlet />
         <Outlet />
       </main>
       </main>
+      <AnnouncementsPanel
+        open={announcementsOpen}
+        onClose={closeAnnouncements}
+        announcements={announcements}
+        markRead={markAnnouncementRead}
+      />
 
 
       <UnknownSpoolModal
       <UnknownSpoolModal
         prompt={unknownSpool.prompt}
         prompt={unknownSpool.prompt}

+ 85 - 0
frontend/src/hooks/useAnnouncements.ts

@@ -0,0 +1,85 @@
+import { useCallback, useMemo } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { announcementsApi, type Announcement, type AnnouncementText } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+
+// Hosts a message may link to. The backend already drops any other link; this
+// is the second check, so a bad link never becomes a clickable one.
+const LINK_HOSTS = ['github.com', 'bambuddy.cool'];
+
+export function isAllowedAnnouncementLink(url: string | null | undefined): url is string {
+  if (!url) return false;
+  try {
+    const parsed = new URL(url);
+    if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return false;
+    if (parsed.port && parsed.port !== '443') return false;
+    const host = parsed.hostname.toLowerCase();
+    return LINK_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
+  } catch {
+    return false;
+  }
+}
+
+/** The message in the UI language when it was written in it, else English. */
+export function announcementText(announcement: Announcement, language: string): AnnouncementText {
+  const { texts } = announcement;
+  if (texts[language]) return texts[language];
+  const lower = language.toLowerCase();
+  const sameCase = Object.keys(texts).find((key) => key.toLowerCase() === lower);
+  if (sameCase) return texts[sameCase];
+  // "pt" finds "pt-BR", "zh-HK" finds "zh-TW" or "zh-CN" -- closer than English.
+  const base = lower.split('-')[0];
+  const sameBase = Object.keys(texts).find((key) => key.toLowerCase().split('-')[0] === base);
+  return sameBase ? texts[sameBase] : texts.en;
+}
+
+const SEVERITY: Record<Announcement['level'], number> = { critical: 2, important: 1, info: 0 };
+
+/**
+ * Announcements from the Bambuddy maintainers, for whoever may see them.
+ *
+ * The backend answers with an empty list for anyone who may not (and when the
+ * install has them switched off), so an empty list is the one signal for
+ * "show nothing" -- no separate permission check here.
+ */
+export function useAnnouncements() {
+  const { authEnabled, user, loading } = useAuth();
+  const queryClient = useQueryClient();
+
+  const { data } = useQuery({
+    queryKey: ['announcements'],
+    queryFn: announcementsApi.list,
+    enabled: !loading && (!authEnabled || !!user),
+    staleTime: 5 * 60 * 1000,
+    // A local read of what the backend fetched; the backend itself only goes
+    // to GitHub every few hours.
+    refetchInterval: 10 * 60 * 1000,
+  });
+  const announcements = useMemo(() => data ?? [], [data]);
+
+  const markReadMutation = useMutation({
+    mutationFn: announcementsApi.markRead,
+    onMutate: (id: string) => {
+      queryClient.setQueryData<Announcement[]>(['announcements'], (old) =>
+        old?.map((a) => (a.id === id ? { ...a, read: true } : a))
+      );
+    },
+    onError: () => queryClient.invalidateQueries({ queryKey: ['announcements'] }),
+  });
+  const { mutate } = markReadMutation;
+  const markRead = useCallback((id: string) => mutate(id), [mutate]);
+
+  const unread = useMemo(() => announcements.filter((a) => !a.read), [announcements]);
+
+  // What earns a banner: important or critical, not yet read. Most severe first,
+  // then newest (the list already comes newest first).
+  const bannerItems = useMemo(
+    () =>
+      unread
+        .filter((a) => a.level !== 'info')
+        .sort((a, b) => SEVERITY[b.level] - SEVERITY[a.level]),
+    [unread]
+  );
+
+  return { announcements, unread, bannerItems, markRead };
+}

+ 20 - 0
frontend/src/i18n/locales/de.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Ankündigungen',
+    unread: 'Ungelesene Ankündigungen: {{count}}',
+    empty: 'Derzeit keine Ankündigungen.',
+    new: 'Neu',
+    readMore: 'Mehr erfahren',
+    readMoreCount: 'Mehr erfahren (+{{count}})',
+    gotIt: 'Verstanden',
+    source: 'Von den Bambuddy-Entwicklern, abgerufen von GitHub. Du kannst sie in den Einstellungen abschalten.',
+    level: {
+      info: 'Hinweis',
+      important: 'Wichtig',
+      critical: 'Kritisch',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} weitere',
     plusNMore: '+{{count}} weitere',
     save: 'Speichern',
     save: 'Speichern',
@@ -2204,6 +2219,11 @@ export default {
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
     includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
+    announcementsEnabled: 'Ankündigungen empfangen',
+    announcementsEnabledDesc: 'Nachrichten der Bambuddy-Entwickler, etwa zu Sicherheitskorrekturen und inkompatiblen Änderungen. Bambuddy lädt alle paar Stunden eine signierte Datei von GitHub; über diese Installation wird nichts gesendet.',
+    announcementsLearnMore: 'Mehr dazu',
+    announcementsAllUsers: 'Allen Benutzern anzeigen',
+    announcementsAllUsersDesc: 'Aus: Nur Administratoren sehen sie. Ohne Wirkung, solange die Authentifizierung aus ist.',
     localLogin: {
     localLogin: {
       disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
       disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
       disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',
       disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',

+ 20 - 0
frontend/src/i18n/locales/en.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Announcements',
+    unread: 'Unread announcements: {{count}}',
+    empty: 'No announcements right now.',
+    new: 'New',
+    readMore: 'Read more',
+    readMoreCount: 'Read more (+{{count}})',
+    gotIt: 'Got it',
+    source: 'From the Bambuddy maintainers, fetched from GitHub. You can turn them off in Settings.',
+    level: {
+      info: 'Info',
+      important: 'Important',
+      critical: 'Critical',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} more',
     plusNMore: '+{{count}} more',
     save: 'Save',
     save: 'Save',
@@ -2224,6 +2239,11 @@ export default {
     checkPrinterFirmware: 'Check printer firmware',
     checkPrinterFirmware: 'Check printer firmware',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
     includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
+    announcementsEnabled: 'Receive announcements',
+    announcementsEnabledDesc: 'Messages from the Bambuddy maintainers, such as security fixes and breaking changes. Bambuddy fetches a signed file from GitHub every few hours; nothing about this install is sent.',
+    announcementsLearnMore: 'Learn more',
+    announcementsAllUsers: 'Show to all users',
+    announcementsAllUsersDesc: 'Off: only administrators see them. Has no effect while authentication is off.',
     localLogin: {
     localLogin: {
       disable: 'Disable local username/password login',
       disable: 'Disable local username/password login',
       disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',
       disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',

+ 20 - 0
frontend/src/i18n/locales/es.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Anuncios',
+    unread: 'Anuncios sin leer: {{count}}',
+    empty: 'No hay anuncios por ahora.',
+    new: 'Nuevo',
+    readMore: 'Leer más',
+    readMoreCount: 'Leer más (+{{count}})',
+    gotIt: 'Entendido',
+    source: 'De los desarrolladores de Bambuddy, obtenidos de GitHub. Puedes desactivarlos en Ajustes.',
+    level: {
+      info: 'Aviso',
+      important: 'Importante',
+      critical: 'Crítico',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} más',
     plusNMore: '+{{count}} más',
     save: 'Guardar',
     save: 'Guardar',
@@ -2207,6 +2222,11 @@ export default {
     checkPrinterFirmware: 'Comprobar el firmware de la impresora',
     checkPrinterFirmware: 'Comprobar el firmware de la impresora',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
     includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
+    announcementsEnabled: 'Recibir anuncios',
+    announcementsEnabledDesc: 'Mensajes de los desarrolladores de Bambuddy, como correcciones de seguridad y cambios incompatibles. Bambuddy descarga un archivo firmado de GitHub cada pocas horas; no se envía nada sobre esta instalación.',
+    announcementsLearnMore: 'Más información',
+    announcementsAllUsers: 'Mostrar a todos los usuarios',
+    announcementsAllUsersDesc: 'Desactivado: solo los administradores los ven. No tiene efecto mientras la autenticación esté desactivada.',
     localLogin: {
     localLogin: {
       disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
       disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
       disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',
       disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',

+ 20 - 0
frontend/src/i18n/locales/fr.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Annonces',
+    unread: 'Annonces non lues : {{count}}',
+    empty: 'Aucune annonce pour le moment.',
+    new: 'Nouveau',
+    readMore: 'En savoir plus',
+    readMoreCount: 'En savoir plus (+{{count}})',
+    gotIt: 'Compris',
+    source: 'Des développeurs de Bambuddy, récupérées depuis GitHub. Vous pouvez les désactiver dans les paramètres.',
+    level: {
+      info: 'Information',
+      important: 'Importante',
+      critical: 'Critique',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} autres',
     plusNMore: '+{{count}} autres',
     save: 'Enregistrer',
     save: 'Enregistrer',
@@ -2159,6 +2174,11 @@ export default {
     checkPrinterFirmware: 'Vérifier le firmware imprimante',
     checkPrinterFirmware: 'Vérifier le firmware imprimante',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
     includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
+    announcementsEnabled: 'Recevoir les annonces',
+    announcementsEnabledDesc: "Messages des développeurs de Bambuddy, comme les correctifs de sécurité et les changements incompatibles. Bambuddy télécharge un fichier signé depuis GitHub toutes les quelques heures ; rien sur cette installation n'est envoyé.",
+    announcementsLearnMore: 'En savoir plus',
+    announcementsAllUsers: 'Afficher à tous les utilisateurs',
+    announcementsAllUsersDesc: "Désactivé : seuls les administrateurs les voient. Sans effet tant que l'authentification est désactivée.",
     localLogin: {
     localLogin: {
       disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
       disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
       disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',
       disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',

+ 20 - 0
frontend/src/i18n/locales/it.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Annunci',
+    unread: 'Annunci non letti: {{count}}',
+    empty: 'Nessun annuncio al momento.',
+    new: 'Nuovo',
+    readMore: 'Scopri di più',
+    readMoreCount: 'Scopri di più (+{{count}})',
+    gotIt: 'Ho capito',
+    source: 'Dagli sviluppatori di Bambuddy, scaricati da GitHub. Puoi disattivarli nelle impostazioni.',
+    level: {
+      info: 'Informazione',
+      important: 'Importante',
+      critical: 'Critico',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} altri',
     plusNMore: '+{{count}} altri',
     save: 'Salva',
     save: 'Salva',
@@ -2159,6 +2174,11 @@ export default {
     checkPrinterFirmware: 'Controlla firmware stampante',
     checkPrinterFirmware: 'Controlla firmware stampante',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
     includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
+    announcementsEnabled: 'Ricevi annunci',
+    announcementsEnabledDesc: 'Messaggi degli sviluppatori di Bambuddy, come correzioni di sicurezza e modifiche incompatibili. Bambuddy scarica un file firmato da GitHub ogni poche ore; nulla su questa installazione viene inviato.',
+    announcementsLearnMore: 'Ulteriori informazioni',
+    announcementsAllUsers: 'Mostra a tutti gli utenti',
+    announcementsAllUsersDesc: "Disattivato: li vedono solo gli amministratori. Nessun effetto finché l'autenticazione è disattivata.",
     localLogin: {
     localLogin: {
       disable: 'Disabilita l\'accesso locale con nome utente/password',
       disable: 'Disabilita l\'accesso locale con nome utente/password',
       disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',
       disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',

+ 20 - 0
frontend/src/i18n/locales/ja.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'お知らせ',
+    unread: '未読のお知らせ: {{count}}',
+    empty: '現在お知らせはありません。',
+    new: '新着',
+    readMore: '詳細を見る',
+    readMoreCount: '詳細を見る (+{{count}})',
+    gotIt: '了解',
+    source: 'Bambuddy 開発者からのお知らせです(GitHub から取得)。設定でオフにできます。',
+    level: {
+      info: '情報',
+      important: '重要',
+      critical: '緊急',
+    },
+  },
   common: {
   common: {
     plusNMore: '他{{count}}件',
     plusNMore: '他{{count}}件',
     save: '保存',
     save: '保存',
@@ -2203,6 +2218,11 @@ export default {
     checkPrinterFirmware: 'プリンターファームウェアの確認',
     checkPrinterFirmware: 'プリンターファームウェアの確認',
     includeBetaUpdates: 'ベータ版を含める',
     includeBetaUpdates: 'ベータ版を含める',
     includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
     includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
+    announcementsEnabled: 'お知らせを受け取る',
+    announcementsEnabledDesc: 'セキュリティ修正や互換性のない変更など、Bambuddy 開発者からのメッセージです。Bambuddy は数時間ごとに GitHub から署名付きファイルを取得します。このインストールに関する情報は一切送信されません。',
+    announcementsLearnMore: '詳細',
+    announcementsAllUsers: 'すべてのユーザーに表示',
+    announcementsAllUsersDesc: 'オフ: 管理者のみに表示されます。認証がオフの間は効果がありません。',
     localLogin: {
     localLogin: {
       disable: 'ローカルのユーザー名/パスワードログインを無効化',
       disable: 'ローカルのユーザー名/パスワードログインを無効化',
       disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',
       disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',

+ 20 - 0
frontend/src/i18n/locales/ko.ts

@@ -30,6 +30,21 @@ export default {
     installApp: '앱 설치',
     installApp: '앱 설치',
     installAppSuccess: 'Bambuddy가 설치되었습니다'
     installAppSuccess: 'Bambuddy가 설치되었습니다'
   },
   },
+  announcements: {
+    title: '공지사항',
+    unread: '읽지 않은 공지사항: {{count}}',
+    empty: '현재 공지사항이 없습니다.',
+    new: '새 소식',
+    readMore: '자세히 보기',
+    readMoreCount: '자세히 보기 (+{{count}})',
+    gotIt: '확인',
+    source: 'Bambuddy 개발자의 공지로, GitHub에서 가져옵니다. 설정에서 끌 수 있습니다.',
+    level: {
+      info: '정보',
+      important: '중요',
+      critical: '긴급',
+    },
+  },
   common: {
   common: {
     plusNMore: '외 {{count}}개',
     plusNMore: '외 {{count}}개',
     save: '저장',
     save: '저장',
@@ -2101,6 +2116,11 @@ export default {
     checkPrinterFirmware: '프린터 펌웨어 확인',
     checkPrinterFirmware: '프린터 펌웨어 확인',
     includeBetaUpdates: '베타 버전 포함',
     includeBetaUpdates: '베타 버전 포함',
     includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
     includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
+    announcementsEnabled: '공지사항 받기',
+    announcementsEnabledDesc: '보안 수정이나 호환되지 않는 변경 사항 등 Bambuddy 개발자의 메시지입니다. Bambuddy는 몇 시간마다 GitHub에서 서명된 파일을 가져오며, 이 설치에 관한 정보는 전송하지 않습니다.',
+    announcementsLearnMore: '자세히 알아보기',
+    announcementsAllUsers: '모든 사용자에게 표시',
+    announcementsAllUsersDesc: '꺼짐: 관리자만 볼 수 있습니다. 인증이 꺼져 있는 동안에는 효과가 없습니다.',
     localLogin: {
     localLogin: {
       disable: '로컬 사용자명/비밀번호 로그인 비활성화',
       disable: '로컬 사용자명/비밀번호 로그인 비활성화',
       disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'
       disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'

+ 20 - 0
frontend/src/i18n/locales/nl.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Aankondigingen',
+    unread: 'Ongelezen aankondigingen: {{count}}',
+    empty: 'Op dit moment geen aankondigingen.',
+    new: 'Nieuw',
+    readMore: 'Meer lezen',
+    readMoreCount: 'Meer lezen (+{{count}})',
+    gotIt: 'Begrepen',
+    source: 'Van de Bambuddy-ontwikkelaars, opgehaald van GitHub. Je kunt ze uitzetten in de instellingen.',
+    level: {
+      info: 'Mededeling',
+      important: 'Belangrijk',
+      critical: 'Kritiek',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} meer',
     plusNMore: '+{{count}} meer',
     save: 'Opslaan',
     save: 'Opslaan',
@@ -2224,6 +2239,11 @@ export default {
     checkPrinterFirmware: 'Printerfirmware controleren',
     checkPrinterFirmware: 'Printerfirmware controleren',
     includeBetaUpdates: 'Bètaversies opnemen',
     includeBetaUpdates: 'Bètaversies opnemen',
     includeBetaUpdatesDesc: 'Melden over bèta- en prereleaseversies bij controle op updates',
     includeBetaUpdatesDesc: 'Melden over bèta- en prereleaseversies bij controle op updates',
+    announcementsEnabled: 'Aankondigingen ontvangen',
+    announcementsEnabledDesc: 'Berichten van de Bambuddy-ontwikkelaars, zoals beveiligingsfixes en incompatibele wijzigingen. Bambuddy haalt om de paar uur een ondertekend bestand op van GitHub; er wordt niets over deze installatie verstuurd.',
+    announcementsLearnMore: 'Meer informatie',
+    announcementsAllUsers: 'Tonen aan alle gebruikers',
+    announcementsAllUsersDesc: 'Uit: alleen beheerders zien ze. Heeft geen effect zolang authenticatie uit staat.',
     localLogin: {
     localLogin: {
       disable: 'Lokale login met gebruikersnaam/wachtwoord uitschakelen',
       disable: 'Lokale login met gebruikersnaam/wachtwoord uitschakelen',
       disableHint: 'Wanneer ingeschakeld kunnen alleen SSO-providers inloggen. LDAP blijft onaangetast. Stel BAMBUDDY_LOCAL_LOGIN=true in op de server om een herstelmogelijkheid te behouden.',
       disableHint: 'Wanneer ingeschakeld kunnen alleen SSO-providers inloggen. LDAP blijft onaangetast. Stel BAMBUDDY_LOCAL_LOGIN=true in op de server om een herstelmogelijkheid te behouden.',

+ 20 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Anúncios',
+    unread: 'Anúncios não lidos: {{count}}',
+    empty: 'Nenhum anúncio no momento.',
+    new: 'Novo',
+    readMore: 'Saiba mais',
+    readMoreCount: 'Saiba mais (+{{count}})',
+    gotIt: 'Entendi',
+    source: 'Dos desenvolvedores do Bambuddy, obtidos do GitHub. Você pode desativá-los nas configurações.',
+    level: {
+      info: 'Aviso',
+      important: 'Importante',
+      critical: 'Crítico',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} outros',
     plusNMore: '+{{count}} outros',
     save: 'Salvar',
     save: 'Salvar',
@@ -2159,6 +2174,11 @@ export default {
     checkPrinterFirmware: 'Verificar firmware da impressora',
     checkPrinterFirmware: 'Verificar firmware da impressora',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
     includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
+    announcementsEnabled: 'Receber anúncios',
+    announcementsEnabledDesc: 'Mensagens dos desenvolvedores do Bambuddy, como correções de segurança e mudanças incompatíveis. O Bambuddy baixa um arquivo assinado do GitHub a cada poucas horas; nada sobre esta instalação é enviado.',
+    announcementsLearnMore: 'Saiba mais',
+    announcementsAllUsers: 'Mostrar para todos os usuários',
+    announcementsAllUsersDesc: 'Desativado: apenas administradores os veem. Sem efeito enquanto a autenticação estiver desativada.',
     localLogin: {
     localLogin: {
       disable: 'Desativar login local com usuário/senha',
       disable: 'Desativar login local com usuário/senha',
       disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',
       disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',

+ 20 - 0
frontend/src/i18n/locales/ru.ts

@@ -30,6 +30,21 @@ export default {
     installApp: "Установить приложение",
     installApp: "Установить приложение",
     installAppSuccess: "Bambuddy установлен",
     installAppSuccess: "Bambuddy установлен",
   },
   },
+  announcements: {
+    title: 'Объявления',
+    unread: 'Непрочитанные объявления: {{count}}',
+    empty: 'Сейчас объявлений нет.',
+    new: 'Новое',
+    readMore: 'Подробнее',
+    readMoreCount: 'Подробнее (+{{count}})',
+    gotIt: 'Понятно',
+    source: 'От разработчиков Bambuddy, загружаются с GitHub. Их можно отключить в настройках.',
+    level: {
+      info: 'Информация',
+      important: 'Важно',
+      critical: 'Критично',
+    },
+  },
   common: {
   common: {
     plusNMore: 'ещё {{count}}',
     plusNMore: 'ещё {{count}}',
     save: "Сохранить",
     save: "Сохранить",
@@ -2098,6 +2113,11 @@ export default {
     checkPrinterFirmware: "Проверять прошивку принтера",
     checkPrinterFirmware: "Проверять прошивку принтера",
     includeBetaUpdates: "Включать бета-версии",
     includeBetaUpdates: "Включать бета-версии",
     includeBetaUpdatesDesc: "Уведомлять о бета- и предварительных версиях при проверке обновлений",
     includeBetaUpdatesDesc: "Уведомлять о бета- и предварительных версиях при проверке обновлений",
+    announcementsEnabled: 'Получать объявления',
+    announcementsEnabledDesc: 'Сообщения от разработчиков Bambuddy, например об исправлениях безопасности и несовместимых изменениях. Bambuddy раз в несколько часов загружает подписанный файл с GitHub; никакие данные об этой установке не отправляются.',
+    announcementsLearnMore: 'Подробнее',
+    announcementsAllUsers: 'Показывать всем пользователям',
+    announcementsAllUsersDesc: 'Выкл.: видят только администраторы. Не действует, пока аутентификация отключена.',
     localLogin: {
     localLogin: {
       disable: "Отключить локальный вход по имени пользователя и паролю",
       disable: "Отключить локальный вход по имени пользователя и паролю",
       disableHint: "Если включено, вход возможен только через провайдеров SSO. На LDAP это не влияет. Чтобы сохранить аварийный способ входа, задайте на сервере BAMBUDDY_LOCAL_LOGIN=true.",
       disableHint: "Если включено, вход возможен только через провайдеров SSO. На LDAP это не влияет. Чтобы сохранить аварийный способ входа, задайте на сервере BAMBUDDY_LOCAL_LOGIN=true.",

+ 20 - 0
frontend/src/i18n/locales/sv.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Meddelanden',
+    unread: 'Olästa meddelanden: {{count}}',
+    empty: 'Inga meddelanden just nu.',
+    new: 'Ny',
+    readMore: 'Läs mer',
+    readMoreCount: 'Läs mer (+{{count}})',
+    gotIt: 'Uppfattat',
+    source: 'Från Bambuddys utvecklare, hämtade från GitHub. Du kan stänga av dem i inställningarna.',
+    level: {
+      info: 'Information',
+      important: 'Viktigt',
+      critical: 'Kritiskt',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} till',
     plusNMore: '+{{count}} till',
     save: 'Spara',
     save: 'Spara',
@@ -2223,6 +2238,11 @@ export default {
     checkPrinterFirmware: 'Kontrollera skrivarens firmware',
     checkPrinterFirmware: 'Kontrollera skrivarens firmware',
     includeBetaUpdates: 'Inkludera betaversioner',
     includeBetaUpdates: 'Inkludera betaversioner',
     includeBetaUpdatesDesc: 'Notis om betaversioner och förhandsversioner vid sökning av uppdateringar',
     includeBetaUpdatesDesc: 'Notis om betaversioner och förhandsversioner vid sökning av uppdateringar',
+    announcementsEnabled: 'Ta emot meddelanden',
+    announcementsEnabledDesc: 'Meddelanden från Bambuddys utvecklare, till exempel säkerhetsrättningar och inkompatibla ändringar. Bambuddy hämtar en signerad fil från GitHub med några timmars mellanrum; ingenting om den här installationen skickas.',
+    announcementsLearnMore: 'Läs mer',
+    announcementsAllUsers: 'Visa för alla användare',
+    announcementsAllUsersDesc: 'Av: endast administratörer ser dem. Ingen effekt så länge autentisering är avstängd.',
     localLogin: {
     localLogin: {
       disable: 'Inaktivera lokal användarnamn/lösenordsinloggning',
       disable: 'Inaktivera lokal användarnamn/lösenordsinloggning',
       disableHint: 'När aktiverat kan endast SSO-leverantörer logga in. LDAP påverkas inte. Sätt BAMBUDDY_LOCAL_LOGIN=true på servern för att behålla en återställningsväg.',
       disableHint: 'När aktiverat kan endast SSO-leverantörer logga in. LDAP påverkas inte. Sätt BAMBUDDY_LOCAL_LOGIN=true på servern för att behålla en återställningsväg.',

+ 20 - 0
frontend/src/i18n/locales/tr.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Ortak
   // Ortak
+  announcements: {
+    title: 'Duyurular',
+    unread: 'Okunmamış duyurular: {{count}}',
+    empty: 'Şu anda duyuru yok.',
+    new: 'Yeni',
+    readMore: 'Devamını oku',
+    readMoreCount: 'Devamını oku (+{{count}})',
+    gotIt: 'Anladım',
+    source: "Bambuddy geliştiricilerinden, GitHub'dan alınır. Ayarlardan kapatabilirsiniz.",
+    level: {
+      info: 'Bilgi',
+      important: 'Önemli',
+      critical: 'Kritik',
+    },
+  },
   common: {
   common: {
     plusNMore: '+{{count}} tane daha',
     plusNMore: '+{{count}} tane daha',
     save: 'Kaydet',
     save: 'Kaydet',
@@ -2207,6 +2222,11 @@ export default {
     checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
     checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
     includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
+    announcementsEnabled: 'Duyuruları al',
+    announcementsEnabledDesc: "Güvenlik düzeltmeleri ve uyumsuz değişiklikler gibi Bambuddy geliştiricilerinden gelen mesajlar. Bambuddy birkaç saatte bir GitHub'dan imzalı bir dosya indirir; bu kurulum hakkında hiçbir şey gönderilmez.",
+    announcementsLearnMore: 'Daha fazla bilgi',
+    announcementsAllUsers: 'Tüm kullanıcılara göster',
+    announcementsAllUsersDesc: 'Kapalı: yalnızca yöneticiler görür. Kimlik doğrulama kapalıyken etkisi yoktur.',
     localLogin: {
     localLogin: {
       disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
       disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
       disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',
       disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',

+ 20 - 0
frontend/src/i18n/locales/uk.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: 'Оголошення',
+    unread: 'Непрочитані оголошення: {{count}}',
+    empty: 'Наразі оголошень немає.',
+    new: 'Нове',
+    readMore: 'Докладніше',
+    readMoreCount: 'Докладніше (+{{count}})',
+    gotIt: 'Зрозуміло',
+    source: 'Від розробників Bambuddy, завантажуються з GitHub. Їх можна вимкнути в налаштуваннях.',
+    level: {
+      info: 'Інформація',
+      important: 'Важливо',
+      critical: 'Критично',
+    },
+  },
   common: {
   common: {
     plusNMore: 'ще {{count}}',
     plusNMore: 'ще {{count}}',
     save: "Зберегти",
     save: "Зберегти",
@@ -2222,6 +2237,11 @@ export default {
     checkPrinterFirmware: "Перевірити прошивку принтера",
     checkPrinterFirmware: "Перевірити прошивку принтера",
     includeBetaUpdates: "Включати бета-версії",
     includeBetaUpdates: "Включати бета-версії",
     includeBetaUpdatesDesc: "Повідомляти про бета-версії та попередні версії під час перевірки оновлень",
     includeBetaUpdatesDesc: "Повідомляти про бета-версії та попередні версії під час перевірки оновлень",
+    announcementsEnabled: 'Отримувати оголошення',
+    announcementsEnabledDesc: 'Повідомлення від розробників Bambuddy, наприклад про виправлення безпеки та несумісні зміни. Bambuddy раз на кілька годин завантажує підписаний файл із GitHub; жодних даних про цю інсталяцію не надсилається.',
+    announcementsLearnMore: 'Докладніше',
+    announcementsAllUsers: 'Показувати всім користувачам',
+    announcementsAllUsersDesc: 'Вимк.: бачать лише адміністратори. Не діє, поки автентифікацію вимкнено.',
     localLogin: {
     localLogin: {
       disable: "Вимкнути локальний вхід за іменем користувача й паролем",
       disable: "Вимкнути локальний вхід за іменем користувача й паролем",
       disableHint: "Якщо ввімкнено, увійти можна буде лише через постачальників SSO. На LDAP це не впливає. Щоб зберегти резервний спосіб входу, установіть на сервері BAMBUDDY_LOCAL_LOGIN=true.",
       disableHint: "Якщо ввімкнено, увійти можна буде лише через постачальників SSO. На LDAP це не впливає. Щоб зберегти резервний спосіб входу, установіть на сервері BAMBUDDY_LOCAL_LOGIN=true.",

+ 20 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: '公告',
+    unread: '未读公告:{{count}}',
+    empty: '暂无公告。',
+    new: '新',
+    readMore: '了解更多',
+    readMoreCount: '了解更多 (+{{count}})',
+    gotIt: '知道了',
+    source: '来自 Bambuddy 开发者,从 GitHub 获取。可在设置中关闭。',
+    level: {
+      info: '信息',
+      important: '重要',
+      critical: '紧急',
+    },
+  },
   common: {
   common: {
     plusNMore: '另 {{count}} 个',
     plusNMore: '另 {{count}} 个',
     save: '保存',
     save: '保存',
@@ -2204,6 +2219,11 @@ export default {
     checkPrinterFirmware: '检查打印机固件',
     checkPrinterFirmware: '检查打印机固件',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
+    announcementsEnabled: '接收公告',
+    announcementsEnabledDesc: '来自 Bambuddy 开发者的消息,例如安全修复和不兼容的变更。Bambuddy 每隔几小时从 GitHub 获取一个已签名的文件;不会发送有关此安装的任何信息。',
+    announcementsLearnMore: '了解更多',
+    announcementsAllUsers: '向所有用户显示',
+    announcementsAllUsersDesc: '关闭:仅管理员可见。身份验证关闭时无效。',
     localLogin: {
     localLogin: {
       disable: '禁用本地用户名/密码登录',
       disable: '禁用本地用户名/密码登录',
       disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',
       disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',

+ 20 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -33,6 +33,21 @@ export default {
   },
   },
 
 
   // Common
   // Common
+  announcements: {
+    title: '公告',
+    unread: '未讀公告:{{count}}',
+    empty: '目前沒有公告。',
+    new: '新',
+    readMore: '了解更多',
+    readMoreCount: '了解更多 (+{{count}})',
+    gotIt: '知道了',
+    source: '來自 Bambuddy 開發者,從 GitHub 取得。可在設定中關閉。',
+    level: {
+      info: '資訊',
+      important: '重要',
+      critical: '緊急',
+    },
+  },
   common: {
   common: {
     plusNMore: '另 {{count}} 個',
     plusNMore: '另 {{count}} 個',
     save: '儲存',
     save: '儲存',
@@ -2204,6 +2219,11 @@ export default {
     checkPrinterFirmware: '檢查印表機韌體',
     checkPrinterFirmware: '檢查印表機韌體',
     includeBetaUpdates: '包含測試版本',
     includeBetaUpdates: '包含測試版本',
     includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
     includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
+    announcementsEnabled: '接收公告',
+    announcementsEnabledDesc: '來自 Bambuddy 開發者的訊息,例如安全性修正和不相容的變更。Bambuddy 每隔幾小時從 GitHub 取得一個已簽署的檔案;不會傳送有關此安裝的任何資訊。',
+    announcementsLearnMore: '了解更多',
+    announcementsAllUsers: '向所有使用者顯示',
+    announcementsAllUsersDesc: '關閉:僅管理員可見。驗證關閉時無效。',
     localLogin: {
     localLogin: {
       disable: '停用本機使用者名稱/密碼登入',
       disable: '停用本機使用者名稱/密碼登入',
       disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',
       disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',

+ 53 - 0
frontend/src/pages/SettingsPage.tsx

@@ -1094,6 +1094,9 @@ export function SettingsPage() {
       // cannot read /settings. Nothing invalidated it, so a currency change
       // cannot read /settings. Nothing invalidated it, so a currency change
       // sat behind that query's own staleTime instead of showing up (#3123).
       // sat behind that query's own staleTime instead of showing up (#3123).
       queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
       queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
+      // Switching announcements off (or to all users) changes who sees the
+      // sidebar entry and the banner; don't wait for the next poll.
+      queryClient.invalidateQueries({ queryKey: ['announcements'] });
       showToast(t('settings.toast.settingsSaved'), 'success');
       showToast(t('settings.toast.settingsSaved'), 'success');
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -1153,6 +1156,8 @@ export function SettingsPage() {
       baseline.check_updates !== localSettings.check_updates ||
       baseline.check_updates !== localSettings.check_updates ||
       (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
       (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
+      (baseline.announcements_enabled ?? true) !== (localSettings.announcements_enabled ?? true) ||
+      (baseline.announcements_all_users ?? false) !== (localSettings.announcements_all_users ?? false) ||
       (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
       (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
       baseline.notification_language !== localSettings.notification_language ||
       baseline.notification_language !== localSettings.notification_language ||
       (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
@@ -1269,6 +1274,8 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         check_printer_firmware: localSettings.check_printer_firmware,
         include_beta_updates: localSettings.include_beta_updates,
         include_beta_updates: localSettings.include_beta_updates,
+        announcements_enabled: localSettings.announcements_enabled ?? true,
+        announcements_all_users: localSettings.announcements_all_users ?? false,
         local_login_enabled: localSettings.local_login_enabled,
         local_login_enabled: localSettings.local_login_enabled,
         notification_language: localSettings.notification_language,
         notification_language: localSettings.notification_language,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
@@ -3103,6 +3110,52 @@ export function SettingsPage() {
                   </p>
                   </p>
                 ) : null}
                 ) : null}
               </div>
               </div>
+              <div className="border-t border-bambu-dark-tertiary pt-4">
+                <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4">{t('announcements.title')}</p>
+              </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">{t('settings.announcementsEnabled')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.announcementsEnabledDesc')}{' '}
+                    <a
+                      href="https://wiki.bambuddy.cool/features/announcements/"
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="text-bambu-green hover:underline"
+                    >
+                      {t('settings.announcementsLearnMore')}
+                    </a>
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.announcements_enabled ?? true}
+                    onChange={(e) => updateSetting('announcements_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              <div className={`flex items-center justify-between ${localSettings.announcements_enabled === false ? 'opacity-50' : ''}`}>
+                <div>
+                  <p className="text-white">{t('settings.announcementsAllUsers')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.announcementsAllUsersDesc')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.announcements_all_users ?? false}
+                    onChange={(e) => updateSetting('announcements_all_users', e.target.checked)}
+                    disabled={localSettings.announcements_enabled === false}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
             </CardContent>
             </CardContent>
           </Card>
           </Card>