diff --git a/CLAUDE.md b/CLAUDE.md index 8264a4a..229dd69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,15 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive), `working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log: `core/applog.py` (`/logs/app.log`, finished GUI jobs in `/logs/jobs/`). + - Announcements (2026-10-15, announcements/README.md; app only, the owner dropped the site pop-up): source of + truth `announcements/current.md` on `main` (front matter id/title/date + optional image/image_alt/button+url/ + expires/active, Markdown body; ships `active: false`). `announcements.py` (no Flet; raw GitHub via + `cache.cached_text`, 6 h) + `ui/announcer.py` (pop-up 20 s after start / every 6 h when no dialog is open and not + on welcome; `C.dialog` with the portal edge, image downloaded + Pillow-checked into `/announcements/`, shown + by asset URL). Shown only if the user was around before `date` (setting `announce.since`, set once at the GUI's + first start: 0 when `USED_BEFORE` settings exist, else now), once: any close (X, outside click, Escape, button) + stores the id (`announce.dismissed`, last 50). New announcement = new id; same id = typo fix. + `FRAMEPORT_NO_ANNOUNCEMENTS`; `ui_smoke.py --announcement` renders it. - Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version (`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub diff --git a/announcements/README.md b/announcements/README.md new file mode 100644 index 0000000..5e6e7c0 --- /dev/null +++ b/announcements/README.md @@ -0,0 +1,40 @@ +# Announcements + +`current.md` is the one announcement FramePort shows as a pop-up in the app. Edit it on `main`: the app reads it +from GitHub `main` (raw.githubusercontent.com) at most every 6 hours, so a push reaches everyone without a release. + +## Who sees it + +- **Only people who already used FramePort before the announcement's `date`.** A new install never shows one, and + nor does an announcement published before FramePort was installed. +- **Once.** Closing it in any way (the X, a click outside it, Escape, or its button) dismisses it for good. The app + remembers dismissals in its settings (`announce.dismissed`). +- **A new announcement = a new `id`.** Fixing a typo with the same `id` doesn't show it again to anyone who closed it. + A new `id` shows to everyone who used FramePort before its `date`, whether they closed the earlier one or not. + +## The file + +```markdown +--- +active: true # false = nothing is shown (the fields are still checked) +id: meetup-2026-11 # letters, digits, . _ - — change it for every new announcement +title: Steam Frame meetup on Discord # required +date: 2026-11-02T18:00:00Z # required: when it was published (UTC; a bare date = midnight UTC) +image: announcements/meetup.png # optional: a file in this folder, or an https URL (PNG, JPEG, WebP, GIF; ≤ 5 MiB) +image_alt: The meetup poster # optional: what the picture shows (screen readers) +button: Join the meetup # optional: an action button, with url +url: https://example.com/meetup +expires: 2026-11-30 # optional: not shown after this day (UTC, inclusive) +--- +The body, in Markdown: **bold**, *italic*, `code`, [links](https://frameport.app/), lists, headings. +``` + +The body is Markdown (no HTML). A `date` in the future works as a schedule: nobody sees it before then. + +Images look best at about 2:1 (e.g. 1200×600). The pop-up is 600 px wide and shows between 140 and 320 px of the +picture's height. + +`pytest tests/test_announcements.py` checks this file, so a broken announcement fails CI instead of silently showing +nothing. + +To turn announcements off for testing: `FRAMEPORT_NO_ANNOUNCEMENTS=1`. diff --git a/announcements/current.md b/announcements/current.md new file mode 100644 index 0000000..444291f --- /dev/null +++ b/announcements/current.md @@ -0,0 +1,16 @@ +--- +# The announcement FramePort shows once (announcements/README.md). Switched off: set active: true. +active: false +id: example-2026-10 +title: Example announcement +date: 2026-10-15T12:00:00Z +# image: announcements/example.png +# image_alt: What the picture shows +# button: Read more +# url: https://frameport.app/changelog/ +# expires: 2026-11-30 +--- +This is where the text goes, in **Markdown**: *emphasis*, `code`, [links](https://frameport.app/) and lists. + +- Keep it short: one or two paragraphs. +- Change `id` for every new announcement. diff --git a/scripts/ui_smoke.py b/scripts/ui_smoke.py index cff2405..04b359c 100644 --- a/scripts/ui_smoke.py +++ b/scripts/ui_smoke.py @@ -101,6 +101,8 @@ def main() -> int: ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440") ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI: " "changelog dialog, what's new bar + dialog, Settings changelog)") + ap.add_argument("--announcement", action="store_true", help="show the announcement pop-up (with a picture, " + "then text only); nothing is fetched") ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)") ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot") ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None, @@ -421,6 +423,26 @@ def main() -> int: print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True) steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions), ("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)] + if args.announcement: + import shutil + + from frameport import announcements + from frameport.core.paths import user_data_dir + + text = ("---\nid: smoke\ntitle: FramePort 1.3 brings the Monitor to every Frame\ndate: 2026-10-14T12:00:00Z\n" + "---\n" + "The **Monitor** tab now shows fps, battery and temperatures for any game the Frame runs, including " + "Steam's own. Open it from the sidebar's Frame card.\n\n" + "- Install links from FrameDrop buttons open in FramePort\n" + "- Uploads over the USB cable are about **3× faster**\n\n" + "Questions? Ask in [Discussions](https://example.invalid).") + news = announcements.parse(text) + pic = user_data_dir() / "announcements" / "smoke.png" + pic.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(Path(__file__).resolve().parents[1] / "docs" / "images" / "library.png", pic) + steps = [("announcement-image", lambda a: (a.navigate(0), a.announcer.show(news, pic))), + ("announcement-text", lambda a: (a.page.pop_dialog(), time.sleep(1.5), a.announcer.show( + announcements.parse(text.replace("id: smoke", "id: smoke2")))))] if args.update: from frameport import __version__, updates diff --git a/src/frameport/announcements.py b/src/frameport/announcements.py new file mode 100644 index 0000000..e43c24a --- /dev/null +++ b/src/frameport/announcements.py @@ -0,0 +1,218 @@ +"""Announcements: one short message from the maintainer, shown once as a pop-up in the app. + +Source of truth: `announcements/current.md` on GitHub `main` (front matter + Markdown, announcements/README.md), read +through raw.githubusercontent like the catalog (cached, every 6 h), so a push reaches users without a release. + +Who sees it: only people who were already using FramePort before the announcement's `date` (a first start never +shows one, and nor does one published before FramePort was installed). Closing it in any way (the X, a click outside, +Escape, its button) dismisses that `id` for good; a new announcement = a new id. No Flet here. +""" +from __future__ import annotations + +import datetime as dt +import hashlib +import logging +import os +import re +import time +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from . import REPO_URL +from .core import cache, library +from .core.paths import user_data_dir + +_log = logging.getLogger("frameport.announcements") + +REPO = REPO_URL.removeprefix("https://github.com/").strip("/") +PATH = "announcements/current.md" +RAW = f"https://raw.githubusercontent.com/{REPO}/main/" +URL = RAW + PATH +CHECK_EVERY = 6 * 3600 +KEEP_DISMISSED = 50 # ids remembered (oldest dropped) +IMAGE_MAX = 5 * 2**20 +ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$") +# settings only an earlier GUI run writes: one of them when announcements first run = an existing user (not +# recipes.app_version: library.load() writes that on the very first load) +USED_BEFORE = ("update.last_seen_version", "update.last_check", "ui.welcome_done", "ui.window") + + +@dataclass(frozen=True) +class Announcement: + id: str + title: str + body: str # Markdown + date: float # unix time it was published + image: str | None = None # https URL (repo paths are resolved to raw GitHub) + image_alt: str = "" + button: str | None = None # optional action: label + url + url: str | None = None + expires: float | None = None + + +class AnnouncementError(ValueError): + pass + + +def _when(value, end_of_day: bool = False) -> float: + """A front-matter date (YAML date/datetime or ISO text) as unix time; a bare date is UTC midnight (or the end of + that day for `expires`). Naive times are UTC.""" + if isinstance(value, str): + text = value.strip().replace("Z", "+00:00") + try: + value = dt.datetime.fromisoformat(text) if "T" in text or " " in text else dt.date.fromisoformat(text) + except ValueError as exc: + raise AnnouncementError(f"not a date: {value!r}") from exc + if isinstance(value, dt.datetime): + return (value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)).timestamp() + if isinstance(value, dt.date): + day = dt.datetime(value.year, value.month, value.day, tzinfo=dt.timezone.utc) + return (day + dt.timedelta(days=1 if end_of_day else 0)).timestamp() + raise AnnouncementError(f"not a date: {value!r}") + + +def _link(value) -> str | None: + """An image/button target: https URLs as they are, a repo path (announcements/x.png) → its raw GitHub URL.""" + if value in (None, ""): + return None + text = str(value).strip() + if text.startswith("https://"): + return text + if "://" in text or text.startswith(("/", "..")) or ".." in text.split("/"): + raise AnnouncementError(f"use an https URL or a path in the repo: {text!r}") + return RAW + text + + +def parse(text: str | None) -> Announcement | None: + """The file's announcement; None when there is none (empty file, `active: false`). Raises AnnouncementError for a + malformed one (the app then shows nothing; tests/test_announcements.py checks the committed file).""" + if not text or not text.strip(): + return None + m = re.match(r"\A?---\s*\n(.*?)\n---\s*(?:\n|\Z)(.*)\Z", text, re.S) + if not m: + raise AnnouncementError("missing the --- front matter") + try: + meta = yaml.safe_load(m.group(1)) or {} + except yaml.YAMLError as exc: + raise AnnouncementError(f"front matter: {exc}") from exc + if not isinstance(meta, dict): + raise AnnouncementError("front matter must be key: value lines") + active = meta.get("active", True) + if active is False and not meta.get("id"): + return None + if not isinstance(active, bool): + raise AnnouncementError("active: true or false") + unknown = set(meta) - {"id", "title", "date", "image", "image_alt", "button", "url", "expires", "active"} + if unknown: + raise AnnouncementError(f"unknown fields: {', '.join(sorted(unknown))}") + ident, title = str(meta.get("id") or "").strip(), str(meta.get("title") or "").strip() + if not ID_RE.match(ident): + raise AnnouncementError("id: letters, digits, '.', '_' or '-' (change it to show a new announcement)") + if not title: + raise AnnouncementError("title is required") + if "date" not in meta: + raise AnnouncementError("date is required (only people using FramePort before it see the announcement)") + button, url = meta.get("button"), meta.get("url") + if bool(button) != bool(url): + raise AnnouncementError("button and url go together") + if not active: # switched off, but still checked (tests catch mistakes before it's switched on) + _check_dates(meta) + return None + return Announcement(id=ident, title=title, body=m.group(2).strip(), date=_when(meta["date"]), + image=_link(meta.get("image")), image_alt=str(meta.get("image_alt") or ""), + button=str(button).strip() if button else None, url=_link(url), + expires=_when(meta["expires"], end_of_day=True) if meta.get("expires") else None) + + +def _check_dates(meta: dict) -> None: + _when(meta["date"]) + if meta.get("expires"): + _when(meta["expires"], end_of_day=True) + _link(meta.get("image")) + _link(meta.get("url")) + + +def disabled() -> bool: + return bool(os.environ.get("FRAMEPORT_NO_ANNOUNCEMENTS")) + + +def fetch(force: bool = False) -> Announcement | None: + """The current announcement (cached 6 h; offline → the last copy; never raises).""" + try: + return parse(cache.cached_text("announcement.md", URL, max_age=0 if force else CHECK_EVERY, fallback="")) + except Exception as exc: # noqa: BLE001 + _log.warning("announcement skipped: %s", exc) + return None + + +# ------------------------------------------------------------------------------------------ who sees it +def since() -> float: + """When this user started using FramePort, as far as announcements are concerned (setting announce.since). Set + once, the first time it's asked: 0 for someone who used FramePort before announcements existed, else now (a first + start). Call it early at start, before anything records this start in the settings.""" + value = library.setting("announce.since") + if isinstance(value, (int, float)): + return float(value) + value = 0.0 if any(library.setting(k) is not None for k in USED_BEFORE) or library.games() else time.time() + library.set_setting("announce.since", value) + return value + + +def dismissed() -> list[str]: + value = library.setting("announce.dismissed") + return [v for v in value if isinstance(v, str)] if isinstance(value, list) else [] + + +def dismiss(ident: str) -> None: + """Never show this announcement again (any way of closing it).""" + library.update_setting("announce.dismissed", + lambda cur: ([v for v in (cur or []) if v != ident] + [ident])[-KEEP_DISMISSED:], []) + + +def due(a: Announcement | None, now: float | None = None, user_since: float | None = None) -> bool: + """Whether to show `a` now: published, not expired, not dismissed, and published after this + user started using FramePort.""" + if a is None: + return False + now = time.time() if now is None else now + if a.expires is not None and now >= a.expires: + return False + if a.date > now: # scheduled for later + return False + if a.id in dismissed(): + return False + return a.date > (since() if user_since is None else user_since) + + +def image_file(a: Announcement) -> Path | None: + """The announcement's image, downloaded once into /announcements/ and checked (Pillow); None without one or + when it can't be loaded (the pop-up then shows no image). Shown by asset URL like artwork, never as bytes.""" + if not a.image: + return None + folder = user_data_dir() / "announcements" + stem = hashlib.sha256(a.image.encode()).hexdigest()[:16] + for done in folder.glob(f"{stem}.*"): + if done.suffix != ".part": + return done + try: + from PIL import Image + + tmp = folder / f"{stem}.part" + cache.download(a.image, tmp) + if tmp.stat().st_size > IMAGE_MAX: + raise AnnouncementError("image larger than 5 MiB") + with Image.open(tmp) as im: + im.verify() + fmt = (im.format or "").lower() + ext = {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "gif": ".gif"}.get(fmt) + if not ext: + raise AnnouncementError(f"image format {fmt or '?'} (use PNG, JPEG, WebP or GIF)") + out = folder / f"{stem}{ext}" + tmp.replace(out) + return out + except Exception as exc: # noqa: BLE001 + _log.warning("announcement image skipped: %s", exc) + (folder / f"{stem}.part").unlink(missing_ok=True) + return None diff --git a/src/frameport/ui/announcer.py b/src/frameport/ui/announcer.py new file mode 100644 index 0000000..978d61d --- /dev/null +++ b/src/frameport/ui/announcer.py @@ -0,0 +1,160 @@ +"""The announcement pop-up (frameport.announcements does the fetching and the who-sees-it rules): checked in the +background a little after start and every 6 h, shown once when no other dialog is open and the welcome screen is done. +Any way of closing it (the X, a click outside, Escape, its button) dismisses that announcement for good.""" +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING + +import flet as ft + +from .. import announcements +from ..core import applog +from ..i18n import tr +from . import components as C +from . import theme as T + +if TYPE_CHECKING: + from ..announcements import Announcement + from .app import FramePortApp + +FIRST_DELAY = 20 # seconds after start: after the start-up work and the update check +BUSY_RETRY = 15 # another dialog is open / the welcome screen is shown: look again +LONG_BODY = 900 # characters: longer bodies scroll inside the pop-up + + +class Announcer: + def __init__(self, app: FramePortApp): + self.app = app + self.shown: str | None = None # id on screen (a check while it's open doesn't show it twice) + try: + announcements.since() # first start → only announcements published after it (before anything is saved) + except Exception: # noqa: BLE001 + applog.log.exception("announcements: recording the first start failed") + + def start(self) -> None: + if not announcements.disabled(): + threading.Thread(target=self._loop, daemon=True).start() + + def _loop(self) -> None: + time.sleep(FIRST_DELAY) + while True: + try: + a = announcements.fetch() + if announcements.due(a): + self._offer(a) + except Exception: # noqa: BLE001 + applog.log.exception("announcement check failed") + time.sleep(announcements.CHECK_EVERY) + + def _offer(self, a: Announcement) -> None: + """Wait for a quiet moment (no dialog, not the welcome screen), then show it (background thread).""" + image = announcements.image_file(a) + while self.app.route[0] == "welcome" or C.dialog_open(self.app.page): + time.sleep(BUSY_RETRY) + if announcements.due(a) and self.shown != a.id: + self.show(a, image) + + # ---------------------------------------------------------------- the pop-up + def show(self, a: Announcement, image=None) -> None: + page = self.app.page + closed = [] + + def dismiss(e=None) -> None: + if closed: + return + closed.append(True) + self.shown = None + try: + announcements.dismiss(a.id) + except Exception: # noqa: BLE001 + applog.log.exception("announcement: saving the dismissal failed") + + def close(e=None) -> None: + dismiss() + page.pop_dialog() + + def act(e=None) -> None: + close() + self.app.open_url(a.url) + + self.shown = a.id + page.show_dialog(C.dialog(None, announcement_card(page, a, image, close, act if a.button else None), + content_padding=0, actions_padding=0, inset_padding=self._insets(), + clip_behavior=ft.ClipBehavior.ANTI_ALIAS, + on_dismiss=dismiss, width=T.DIALOG_M)) + + + def _insets(self) -> ft.Padding: + """Centred over the content area (right of the sidebar, left of an open activity panel), not the whole + window; where that area is narrower than the pop-up, the panel and then the sidebar are left out.""" + gap = T.S5 + try: + left, right = self.app.content_insets() + width = float(self.app.page.width or 0) + except Exception: # noqa: BLE001 + return ft.Padding.all(gap) + need = T.DIALOG_M + 2 * gap + if width and width - left - right < need: + right = 0 + if width and width - left - right < need: + left = 0 + return ft.Padding(left + gap, gap, right + gap, gap) + + +def announcement_card(page: ft.Page, a: Announcement, image, on_close, on_action=None) -> ft.Control: + """The pop-up's content: the portal edge, the picture (if any) with the close button over it, title, date, the + Markdown body and the optional action.""" + from ..artwork.thumbs import asset_url + from .updater import _notes_style, _open_url + + width = T.DIALOG_M + close_tip = tr("Dismiss") + title = ft.Text(a.title, size=T.px(20), weight=ft.FontWeight.W_700, color=T.TEXT, selectable=True) + date = C.meta(time.strftime("%Y-%m-%d", time.localtime(a.date))) + body = ft.Markdown(a.body, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, + md_style_sheet=_notes_style(), + on_tap_link=lambda e: _open_url(page, e.data)) + if len(a.body) > LONG_BODY: + body = ft.Column([body], scroll=ft.ScrollMode.AUTO, height=T.px(320)) + + head: list[ft.Control] = [ft.Container(height=T.px(3), gradient=C.portal_gradient())] + if image is not None: + head.append(ft.Stack([ + ft.Image(src=asset_url(image), width=width, height=_image_height(image, width), fit=ft.BoxFit.COVER, + semantics_label=a.image_alt or None), + ft.Container( + ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(18), color=T.TEXT), + width=T.px(34), height=T.px(34), border_radius=T.px(17), alignment=ft.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.62, T.BG), border=ft.Border.all(1, T.soft(T.TEXT, 0.18)), + tooltip=close_tip, ink=True, on_click=on_close, right=T.S3, top=T.S3), + ], width=width)) + title_row: ft.Control = title + else: + title_row = ft.Row([ft.Container(title, expand=True, padding=ft.Padding(0, T.px(6), 0, 0)), + C.icon_btn(ft.Icons.CLOSE_ROUNDED, close_tip, on_click=on_close)], + vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S2) + + text = [ft.Column([title_row, date], spacing=T.px(2), tight=True)] + if a.body: + text.append(body) + if on_action is not None: + text.append(ft.Row([C.primary(a.button, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=on_action)], + alignment=ft.MainAxisAlignment.END)) + pad = ft.Padding(T.S5, T.S4 if image is None else T.S5, T.S4 if image is None else T.S5, T.S5) + return ft.Container( + ft.Column([*head, ft.Container(ft.Column(text, spacing=T.S4, tight=True), padding=pad)], spacing=0, tight=True), + width=width) + + +def _image_height(path, width: float) -> float: + """The picture's height at the pop-up's width, kept between a banner and a third of a 16:9 screen.""" + try: + from PIL import Image + + with Image.open(path) as im: + ratio = im.height / im.width + except Exception: # noqa: BLE001 + ratio = 9 / 21 + return max(T.px(140), min(width * ratio, T.px(320))) diff --git a/src/frameport/ui/app.py b/src/frameport/ui/app.py index 3db627f..3f7448b 100644 --- a/src/frameport/ui/app.py +++ b/src/frameport/ui/app.py @@ -96,6 +96,9 @@ def reduce_motion_setting() -> bool: return bool(library.setting(REDUCE_MOTION, False)) +SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets) + + class FramePortApp: def __init__(self, page: ft.Page): from .views.library import load_filters @@ -145,9 +148,12 @@ class FramePortApp: self.stop_live(), # and stop a live view (it would keep the Frame encoding) self.stop_keyboard(), self.stop_monitor(), self.monitor_hub.close(), _link_state.update(owner=None) if _link_state["owner"] is self else None) + from .announcer import Announcer from .updater import Updater self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update) + # maintainer announcements (pop-up once, not on a first start); before updater.start() saves this start + self.announcer = Announcer(self) page.title = tr("FramePort") for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance) @@ -182,6 +188,7 @@ class FramePortApp: self._start_auto_pair() threading.Thread(target=self._backfill_covers, daemon=True).start() self.updater.start() + self.announcer.start() self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed) _link_state["owner"] = self if _link_state["thread"] is None: @@ -236,13 +243,18 @@ class FramePortApp: self.activity_card, self.conn_card, self.power_row, # under the Frame card (owner's choice) - ], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4, + ], spacing=T.S2), width=T.px(SIDEBAR_WIDTH), bgcolor=T.SIDEBAR, padding=T.S4, border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER))) # dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else [] self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH)] + def content_insets(self) -> tuple[float, float]: + """How much of the window's left (the sidebar + its edge) and right (the activity panel, when open) isn't the + content area: pop-ups about the content (announcements) centre over what's between.""" + return T.px(SIDEBAR_WIDTH) + (2 if T.DUAL else 0), float(self.activity.root.width or 0) + def restyle(self, theme: str) -> None: """Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views FramePort keeps between visits recreated (the files/screenshots views start at their first location again; diff --git a/tests/test_announcements.py b/tests/test_announcements.py new file mode 100644 index 0000000..468ef2c --- /dev/null +++ b/tests/test_announcements.py @@ -0,0 +1,140 @@ +"""Announcements: the committed file parses, who sees one (never on a first start), and dismissing is for good.""" +from pathlib import Path + +import pytest + +from frameport import announcements as A +from frameport.core import library + +ROOT = Path(__file__).resolve().parents[1] + +DOC = """--- +id: one +title: Hello +date: 2026-10-10T12:00:00Z +--- +Some **news**. +""" + + +def ann(text=DOC): + a = A.parse(text) + assert a is not None + return a + + +def test_committed_announcement_is_valid(): + """announcements/current.md must parse (an inactive one is checked too): a typo fails here, not on users.""" + A.parse((ROOT / A.PATH).read_text(encoding="utf-8")) + + +def test_parse_fields(): + a = A.parse("""--- +id: frameport-1.3 +title: "FramePort 1.3: the Monitor" +date: 2026-11-02 +image: announcements/x.png +image_alt: A picture +button: Read more +url: https://frameport.app/changelog/ +expires: 2026-11-30 +--- +Body *here*.""") + assert a.id == "frameport-1.3" and a.title == "FramePort 1.3: the Monitor" and a.body == "Body *here*." + assert a.image == A.RAW + "announcements/x.png" and a.image_alt == "A picture" + assert a.button == "Read more" and a.url == "https://frameport.app/changelog/" + assert a.date == 1793577600.0 # 2026-11-02 00:00 UTC + assert a.expires == 1795996800.0 + 86400 # end of 2026-11-30 (inclusive) + + +@pytest.mark.parametrize("text", ["", " \n", "---\nactive: false\n---\n"]) +def test_nothing_to_show(text): + assert A.parse(text) is None + + +@pytest.mark.parametrize("front, error", [ + ("title: x\ndate: 2026-10-10", "id"), + ("id: bad id\ntitle: x\ndate: 2026-10-10", "id"), + ("id: a\ndate: 2026-10-10", "title"), + ("id: a\ntitle: x", "date"), + ("id: a\ntitle: x\ndate: soon", "date"), + ("id: a\ntitle: x\ndate: 2026-10-10\nbutton: Go", "button and url"), + ("id: a\ntitle: x\ndate: 2026-10-10\nimage: http://example.com/x.png", "https"), + ("id: a\ntitle: x\ndate: 2026-10-10\nimage: ../secret.png", "https"), + ("id: a\ntitle: x\ndate: 2026-10-10\nwhere: app", "unknown"), # the site pop-up was dropped + ("id: a\ntitle: x\ndate: 2026-10-10\ncolour: red", "unknown"), + ("active: false\nid: a\ntitle: x\ndate: tomorrow", "date"), # switched off, still checked +]) +def test_parse_errors(front, error): + with pytest.raises(A.AnnouncementError, match=error): + A.parse(f"---\n{front}\n---\nbody") + + +def test_fetch_never_raises_on_a_broken_file(monkeypatch): + monkeypatch.setattr(A.cache, "cached_text", lambda *a, **k: "no front matter") + assert A.fetch() is None + + +def test_first_start_sees_no_older_announcement(): + """A new install: `since` = now, so an announcement published before it never shows.""" + assert A.since() > 1.9e9 - 2e8 # roughly now + assert not A.due(ann()) + + +def test_existing_user_sees_it_once(): + library.set_setting("update.last_seen_version", "1.2.0") # FramePort ran before announcements existed + assert A.since() == 0 + a = ann() + assert A.due(a) + A.dismiss(a.id) + assert not A.due(a) + assert not A.due(a) # for good, also at the next check + # a typo fixed under the same id stays dismissed; a new id shows again + assert not A.due(ann(DOC.replace("Hello", "Hallo"))) + assert A.due(ann(DOC.replace("id: one", "id: two"))) + + +def test_since_is_recorded_once(): + first = A.since() + library.set_setting("update.last_seen_version", "1.2.0") # written later by this same start + assert A.since() == first + + +def test_new_user_sees_later_announcements(): + start = 1_800_000_000.0 + later = ann(DOC.replace("2026-10-10T12:00:00Z", "2027-01-20T00:00:00Z")) # after `start` + assert A.due(later, now=later.date + 60, user_since=start) + assert not A.due(ann(), now=later.date + 60, user_since=start) # published before the install + + +def test_schedule_and_expiry(): + a = ann(DOC.replace("---\nSome", "expires: 2026-10-12\n---\nSome")) + assert not A.due(a, now=a.date - 60, user_since=0) # not published yet + assert A.due(a, now=a.date + 60, user_since=0) + assert not A.due(a, now=a.expires + 1, user_since=0) # expired + + +def test_dismissed_list_is_bounded(): + for i in range(A.KEEP_DISMISSED + 5): + A.dismiss(f"id{i}") + kept = A.dismissed() + assert len(kept) == A.KEEP_DISMISSED and kept[-1] == f"id{A.KEEP_DISMISSED + 4}" + A.dismiss("id10") # again: moves to the end, no duplicate + assert A.dismissed().count("id10") == 1 and A.dismissed()[-1] == "id10" + + +def test_image_file_checks_the_picture(monkeypatch, tmp_path): + from PIL import Image + + src = tmp_path / "pic.png" + Image.new("RGB", (40, 20), "orange").save(src) + + def fake_download(url, dest, **kw): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(src.read_bytes() if url.endswith(".png") else b"not an image") + return dest + monkeypatch.setattr(A.cache, "download", fake_download) + good = A.image_file(ann(DOC.replace("---\nSome", "image: announcements/a.png\n---\nSome"))) + assert good is not None and good.suffix == ".png" and good.exists() + assert A.image_file(ann(DOC.replace("---\nSome", "image: https://example.com/a.jpg\n---\nSome"))) is None + assert not list(good.parent.glob("*.part"))