diff --git a/apps/about/main.py b/apps/about/main.py index f4febf4..99695e7 100644 --- a/apps/about/main.py +++ b/apps/about/main.py @@ -80,6 +80,14 @@ def on_button_press(self, btn): elif btn == api.BTN_DOWN: self._scroll = min(self._max_scroll, self._scroll + 12) self._dirty = True + elif btn == api.BTN_A: + # Version is the headline detail on this page — pressing A + # opens the dedicated Updates app where the user can run + # Check + Install. Matches the Settings → Version flow. + try: + self._os.launch("updates") + except Exception: + pass def _info_rows(self): secs = self._last_s diff --git a/apps/launcher/main.py b/apps/launcher/main.py index 297f1a7..b7657fb 100644 --- a/apps/launcher/main.py +++ b/apps/launcher/main.py @@ -297,7 +297,7 @@ def on_enter(self, os): # bt + wifi are surfaced through Settings rather than as their # own drawer tiles — Settings has dedicated rows that launch # those screens, so a separate tile would just be duplication. - DRAWER_HIDDEN = ("launcher", "bt", "wifi", "gestures") + DRAWER_HIDDEN = ("launcher", "bt", "wifi", "gestures", "updates") self._apps = [a for a in list_apps() if a["dir"] not in DRAWER_HIDDEN] # Icon cache lives at module scope (see _ICON_CACHE above). We diff --git a/apps/settings/main.py b/apps/settings/main.py index 0df9c05..09bf6aa 100644 --- a/apps/settings/main.py +++ b/apps/settings/main.py @@ -114,12 +114,13 @@ def on_enter(self, os): getter=lambda: self._gestures_summary(), setter=lambda v: self._open_gestures()), - _Row("Version", "info", - getter=self._os_version), - _Row("Check Update","action", - setter=lambda v: self._check_update()), - _Row("Install Update","action", - setter=lambda v: self._install_update()), + # Version → tap to open the full Updates page (Check + + # Install live there now, mirroring the GitHub Releases UI). + # The value column shows the version string so the row + # still reads as a status line at a glance. + _Row("Version", "action", + getter=self._os_version, + setter=lambda v: self._open_updates()), _Row("Reboot", "action", setter=lambda v: self._reboot()), ] @@ -204,104 +205,15 @@ def _os_version(): except Exception: return "?" - # ── OTA actions ────────────────────────────────────────────────────── - # Three-stage flow so the UI never blocks on a long HTTP call without - # the user knowing what's happening: - # 1. check() — hit GitHub releases (T_GH_API timeout, ~10 s max) - # 2. peek() — fetch the manifest, compute SHA diff, total bytes - # 3. download() — only the changed files (bounded per-file timeout) - # - # We store intermediate state on the OS settings dict so the home - # screen / About page can render a status pill without re-checking. - def _check_update(self): + # ── Updates page launcher ─────────────────────────────────────────── + # All version / OTA actions live in apps/updates/ now. Settings just + # forwards there, same pattern as WiFi / BT / Gestures. + def _open_updates(self): try: - from oreoOS import ota - except Exception: - self._set_ota_status("error"); return - - rel = self._ota_safe(lambda: ota.check()) - if not rel: - self._set_ota_status("up-to-date") - return - - # Surface the discovery in the notification ring so the user - # sees the new version arrive even if they leave Settings before - # the install finishes. De-duped on version inside the helper. - try: - ota.push_update_notification(rel.get("version", "")) + self._os.launch("updates") except Exception: pass - peeked = self._ota_safe(lambda: ota.peek(rel)) - if not peeked: - self._set_ota_status("peek-failed") - return - - # Park everything we just learned so _install_update / the - # confirmation popup can act on it without re-fetching. - self._os.settings_set("ota_pending_version", rel.get("version", "")) - self._os.settings_set("ota_pending_bytes", peeked["bytes"]) - self._os.settings_set("ota_pending_major", peeked["major"]) - self._os.settings_set("ota_pending_changed", len(peeked["changed"])) - # Stash the manifest URL too in case the popup needs to re-peek - # on the next boot (cleared in _install_update). - self._os.settings_set("ota_pending_url", rel["manifest_url"]) - - # Small + non-major patches auto-stage in the background. Anything - # bigger requires user confirmation — Settings flips a flag the - # home screen reads to draw a confirmation popup. - if peeked["small"] and not peeked["major"]: - self._set_ota_status("downloading") - ok = self._ota_safe(lambda: ota.download(peeked)) - self._set_ota_status("ready" if ok else "download-failed") - else: - # Defer to the popup. The home screen reads this flag. - self._set_ota_status("needs-confirm") - self._os.settings_set("ota_pending_peek_ok", True) - - def _install_update(self): - """Triggered after the user confirms in the popup. Downloads (if - not already staged) then reboots so the boot hook applies.""" - try: - from oreoOS import ota - except Exception: - return - - if not ota.is_pending(): - # Need to actually fetch the bytes first. Re-peek so we don't - # rely on transient state; this is the user-explicit path so - # showing "downloading" is appropriate. - rel = self._ota_safe(lambda: ota.check()) - if not rel: - return - peeked = self._ota_safe(lambda: ota.peek(rel)) - if not peeked: - return - self._set_ota_status("downloading") - ok = self._ota_safe(lambda: ota.download(peeked)) - if not ok: - self._set_ota_status("download-failed") - return - self._set_ota_status("ready") - # Clear the popup flag and kick the chip. - self._os.settings_set("ota_pending_peek_ok", False) - self._reboot() - - def _set_ota_status(self, s): - try: - self._os.settings_set("ota_status", s) - except Exception: - pass - - @staticmethod - def _ota_safe(callable_): - """Run an OTA call inside try/except so a thrown HTTP error never - bubbles up into the UI's button-press handler.""" - try: - return callable_() - except Exception: - return None - def _reboot(self): try: import machine diff --git a/apps/updates/__init__.py b/apps/updates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/updates/main.py b/apps/updates/main.py new file mode 100644 index 0000000..b688eda --- /dev/null +++ b/apps/updates/main.py @@ -0,0 +1,394 @@ +"""Updates — single page for everything version-related. + +Reached from: + * Settings → Version (replaces the old Check / Install rows) + * About → tap the version line + * Notification panel → OTA notification + +What it shows: + * Current OS version (from oreoOS/config.py / launcher.VERSION) + * On entry, auto-runs `ota.check()` so the page is meaningful + without an extra tap. Status line reflects state of the check. + * If a newer release exists, displays its tag + size + release-notes + excerpt (`body` field from the GitHub releases API — the same text + that shows on the GitHub Releases page). + * Install button is *only* active when a newer release is staged + or downloadable; otherwise it's a dim disabled chip. + +Controls: + UP / DOWN pick a row (Refresh / Install) + A activate the focused row + HOME back to launcher +""" + +import oreoOS +from oreoOS import api, theme, widgets + + +SW = api.SCREEN_W +SH = api.SCREEN_H + +ROW_PAD_X = 12 +HEADER_Y = widgets.HEADER_H + 6 + +# Layout slots — top block is read-only state, bottom is two action rows. +VER_Y = HEADER_Y +VER_H = 44 +NOTES_Y = VER_Y + VER_H + 6 +NOTES_H = 90 +ACTION_TOP_Y = NOTES_Y + NOTES_H + 10 +ACTION_H = 22 +ACTION_GAP = 6 + +# States the page can be in. Maps to the OTA settings dict but with a +# UX-meaningful name so the renderer can switch on it directly. +S_IDLE = "idle" +S_CHECKING = "checking" +S_UP_TO_DATE = "up_to_date" +S_AVAILABLE = "available" +S_DOWNLOADING = "downloading" +S_READY = "ready" +S_FAILED = "failed" + + +class App(oreoOS.App): + name = "Updates" + author = "Circuit-Overtime" + SHOW_LOADING = True + + # Logical action rows (selectable). + ROW_REFRESH, ROW_INSTALL = 0, 1 + ROWS = (ROW_REFRESH, ROW_INSTALL) + + def on_enter(self, os_): + super().on_enter(os_) + self._os = os_ + self._sel = self.ROW_REFRESH + self._dirty = True + + # State backing the rendering. _release / _peeked are cached + # results of ota.check() and ota.peek() so we don't refetch on + # every redraw. + self._state = S_IDLE + self._release = None + self._peeked = None + self._error = "" + + # Auto-run the check on entry — most users open this page + # because they want to know whether there's an update, so + # making them press A immediately is friction. + self._run_check() + + # ── input ────────────────────────────────────────────────────────── + def on_button_press(self, btn): + if btn == api.BTN_HOME: + self._os.quit() + return + if btn == api.BTN_UP: + self._sel = (self._sel - 1) % len(self.ROWS) + elif btn == api.BTN_DOWN: + self._sel = (self._sel + 1) % len(self.ROWS) + elif btn == api.BTN_A: + self._activate() + else: + return + self._dirty = True + + def _activate(self): + r = self._sel + if r == self.ROW_REFRESH: + self._run_check() + elif r == self.ROW_INSTALL and self._install_enabled(): + self._run_install() + + def _install_enabled(self): + """Install row is active only when there's a discovered release + we can either re-download or apply on reboot.""" + return self._state in (S_AVAILABLE, S_READY) + + # ── OTA wrappers ─────────────────────────────────────────────────── + def _run_check(self): + try: + from oreoOS import ota + except Exception: + self._state = S_FAILED + self._error = "ota module missing" + return + + self._state = S_CHECKING + self._dirty = True + # Paint the "checking" state before the synchronous HTTP fires + # so the user sees feedback during the (up to T_GH_API seconds) + # network round-trip. + try: + self.draw(self._os.display) + self._os.display.present() + except Exception: + pass + + rel = self._safe(lambda: ota.check()) + if not rel: + self._state = S_UP_TO_DATE + try: + self._os.settings_set("ota_status", "up-to-date") + except Exception: + pass + self._dirty = True + return + + # Surface the discovery in the global notification ring AND + # peek so we know the byte count + whether the patch is small. + try: + ota.push_update_notification(rel.get("version", "")) + except Exception: + pass + peeked = self._safe(lambda: ota.peek(rel)) + if not peeked: + self._state = S_FAILED + self._error = "couldn't read manifest" + self._dirty = True + return + + self._release = rel + self._peeked = peeked + + try: + self._os.settings_set("ota_pending_version", rel.get("version", "")) + self._os.settings_set("ota_pending_bytes", peeked["bytes"]) + self._os.settings_set("ota_pending_major", peeked["major"]) + self._os.settings_set("ota_pending_changed", len(peeked["changed"])) + self._os.settings_set("ota_pending_url", rel["manifest_url"]) + self._os.settings_set("ota_status", "available") + except Exception: + pass + + self._state = S_AVAILABLE + self._dirty = True + + def _run_install(self): + try: + from oreoOS import ota + except Exception: + self._state = S_FAILED + return + # If the bytes are already staged, just reboot — the boot path + # applies them. Otherwise download first. + if not ota.is_pending(): + if not self._peeked: + # Reflect a fresh check that might've expired between + # opening the page and pressing Install. + rel = self._safe(lambda: ota.check()) + if not rel: + self._state = S_UP_TO_DATE + self._dirty = True + return + self._peeked = self._safe(lambda: ota.peek(rel)) + if not self._peeked: + self._state = S_FAILED + self._dirty = True + return + self._state = S_DOWNLOADING + self._dirty = True + try: + self.draw(self._os.display); self._os.display.present() + except Exception: + pass + ok = self._safe(lambda: ota.download(self._peeked)) + if not ok: + self._state = S_FAILED + self._error = "download failed" + try: self._os.settings_set("ota_status", "download-failed") + except Exception: pass + self._dirty = True + return + try: self._os.settings_set("ota_status", "ready") + except Exception: pass + self._state = S_READY + try: + self._os.settings_set("ota_pending_peek_ok", False) + except Exception: + pass + # Boot path applies the staged manifest before the launcher runs. + try: + import machine + machine.reset() + except Exception: + self._os.quit() + + @staticmethod + def _safe(fn): + try: + return fn() + except Exception: + return None + + # ── render ───────────────────────────────────────────────────────── + def draw(self, d): + if not self._dirty: + return + self._dirty = False + d.clear(theme.BG) + widgets.draw_header(d, "UPDATES") + widgets.draw_hint(d, "A=run HOME=back") + + self._draw_version_card(d) + self._draw_notes_card(d) + self._draw_action_row(d, self.ROW_REFRESH, + y=ACTION_TOP_Y, + label=self._refresh_label(), + enabled=True) + self._draw_action_row(d, self.ROW_INSTALL, + y=ACTION_TOP_Y + ACTION_H + ACTION_GAP, + label=self._install_label(), + enabled=self._install_enabled()) + + def _draw_version_card(self, d): + x, y, w, h = 6, VER_Y, SW - 12, VER_H + d.rect(x, y, w, h, theme.CARD, fill=True) + d.rect(x, y, w, 3, theme.PRIMARY, fill=True) + d.text("Current", x + 8, y + 6, theme.MUTED, scale=1) + cur = self._current_version() + d.text(cur, x + 8, y + 20, theme.PRIMARY, scale=2) + + # State pill on the right. + pill, pcol = self._state_pill() + if pill: + pw = len(pill) * 8 + 12 + d.rect(x + w - pw - 8, y + 18, pw, 16, + pcol, fill=True) + d.text(pill, x + w - pw - 8 + 6, y + 22, api.WHITE, scale=1) + + def _draw_notes_card(self, d): + x, y, w, h = 6, NOTES_Y, SW - 12, NOTES_H + d.rect(x, y, w, h, theme.DOCK_SEL, fill=True) + d.rect(x, y, w, 1, theme.MUTED2, fill=True) + d.rect(x, y + h - 1, w, 1, theme.MUTED2, fill=True) + + if self._state == S_CHECKING: + self._centred(d, "checking GitHub releases...", + y + h // 2 - 4, theme.MUTED) + return + if self._state == S_DOWNLOADING: + self._centred(d, "downloading update bytes...", + y + h // 2 - 4, theme.PRIMARY) + return + if self._state == S_UP_TO_DATE: + self._centred(d, "You're on the latest version", + y + h // 2 - 4, theme.TEAL) + return + if self._state == S_FAILED: + self._centred(d, "Last attempt failed:", + y + h // 2 - 12, theme.PRIMARY) + self._centred(d, (self._error or "try again")[:36], + y + h // 2 + 2, theme.MUTED) + return + if self._state in (S_AVAILABLE, S_READY): + rel = self._release or {} + ver = rel.get("version", "?") + d.text("New release", x + 8, y + 6, theme.PRIMARY, scale=1) + d.text(ver, x + 8, y + 18, theme.TEXT_BRIGHT, scale=2) + # Bytes line — right-aligned to give space for the notes body. + if self._peeked: + kb = max(1, self._peeked["bytes"] // 1024) + meta = "%d KB · %d files" % (kb, + len(self._peeked["changed"])) + d.text(meta, x + w - len(meta) * 8 - 8, y + 22, + theme.MUTED, scale=1) + # Release-notes excerpt — pull from rel["notes"] (the + # GitHub release body). 3 lines, hard-wrapped, ellipsis on + # overflow. Mirrors what shows on the GitHub Releases page + # for this tag. + notes_y = y + 42 + for i, line in enumerate(self._wrap_notes(rel.get("notes") or "", + (w - 16) // 8, 3)): + d.text(line, x + 8, notes_y + i * 12, + theme.TEXT_DIM, scale=1) + return + # idle (rarely seen — we auto-check on entry) + self._centred(d, "Press A on 'Check for updates'", + y + h // 2 - 4, theme.MUTED) + + def _draw_action_row(self, d, idx, y, label, enabled): + x = 6 + w = SW - 12 + sel = (idx == self._sel) + bg = theme.DOCK_SEL if sel else theme.CARD + d.rect(x, y, w, ACTION_H, bg, fill=True) + if sel: + d.rect(x, y, w, 1, theme.SEL_BORDER, fill=True) + d.rect(x, y + ACTION_H - 1, w, 1, theme.SEL_BORDER, fill=True) + d.rect(x, y, 1, ACTION_H, theme.SEL_BORDER, fill=True) + d.rect(x + w - 1, y, 1, ACTION_H, theme.SEL_BORDER, fill=True) + color = (theme.TEXT_BRIGHT if enabled else theme.MUTED2) + d.text(label, x + 10, y + 7, color, scale=1) + # Right-edge state hint + if idx == self.ROW_INSTALL and not enabled: + hint = "no update" + d.text(hint, x + w - len(hint) * 8 - 10, + y + 7, theme.MUTED2, scale=1) + + # ── label / state helpers ───────────────────────────────────────── + def _refresh_label(self): + if self._state == S_CHECKING: + return "Checking..." + return "Check for updates" + + def _install_label(self): + if self._state == S_DOWNLOADING: + return "Downloading..." + if self._state == S_READY: + return "Install + reboot" + if self._state == S_AVAILABLE: + ver = (self._release or {}).get("version", "") + return ("Install " + ver) if ver else "Install update" + return "Install update" + + def _state_pill(self): + if self._state == S_CHECKING: return ("CHECKING", theme.MUTED) + if self._state == S_DOWNLOADING: return ("DOWNLOADING", theme.PRIMARY) + if self._state == S_AVAILABLE: return ("UPDATE", theme.PRIMARY) + if self._state == S_READY: return ("READY", theme.PRIMARY) + if self._state == S_UP_TO_DATE: return ("LATEST", theme.TEAL) + if self._state == S_FAILED: return ("ERROR", theme.PRIMARY) + return (None, None) + + @staticmethod + def _current_version(): + try: + from oreoOS.config import VERSION + return VERSION + except Exception: + return "?" + + @staticmethod + def _wrap_notes(text, max_chars, max_lines): + """Soft word-wrap with a hard ellipsis on overflow. Returns a + list of up to `max_lines` strings.""" + out = [] + rest = (text or "").split() + cur = "" + while rest and len(out) < max_lines: + w = rest[0] + cand = (cur + " " + w) if cur else w + if len(cand) <= max_chars: + cur = cand + rest.pop(0) + continue + if cur: + out.append(cur) + cur = "" + continue + out.append(w[:max_chars]) + rest[0] = w[max_chars:] + if cur and len(out) < max_lines: + out.append(cur) + if rest and out: + # Trailing ellipsis on the last line if the body got cut off. + last = out[-1] + cut = max_chars - 1 + out[-1] = (last[:cut].rstrip() + "…") if len(last) > cut else last + "…" + return out + + @staticmethod + def _centred(d, msg, y, color): + d.text(msg, (SW - len(msg) * 8) // 2, y, color, scale=1) diff --git a/apps/updates/manifest.json b/apps/updates/manifest.json new file mode 100644 index 0000000..7a1762d --- /dev/null +++ b/apps/updates/manifest.json @@ -0,0 +1 @@ +{"name": "Updates", "type": "app", "version": "0.1", "icon": "settings_icon.png", "author": "Circuit-Overtime"} diff --git a/oreoOS/ota.py b/oreoOS/ota.py index e931313..c7b85a1 100644 --- a/oreoOS/ota.py +++ b/oreoOS/ota.py @@ -584,7 +584,7 @@ def push_update_notification(version): for n in notifications.items()) if not already: notifications.push("ota", "Update available", - version, target="settings") + version, target="updates") except Exception: pass