mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 08:01:24 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a778a6c2 | ||
|
|
9739fdcb09 | ||
|
|
192ab7b5de |
No files matched your search
@@ -267,6 +267,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` (`<data>/logs/app.log`, finished GUI jobs in `<data>/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 `<data>/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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
@@ -418,6 +420,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
|
||||
|
||||
|
||||
@@ -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 <data>/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
|
||||
@@ -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)))
|
||||
+13
-1
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
Reference in new issue
Block a user