diff --git a/CHANGELOG.md b/CHANGELOG.md index d5575ac..0546384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to SearchMob Desktop are documented here. The version scheme is Ubuntu-style `YY.MM.VV` and releases are tagged `vYY.MM.VV`. +## Unreleased + +### Added +- **You now actually get told when an update is out.** When SearchMob is open and a newer release is + available, it posts a system notification and shows a banner across the top of the window; the + served search pages show the same banner (only to you, on this device, never to other people on + your network). The check still runs about once a day through the privacy proxy and stays opt-out. +- **One-click update.** Clicking **Update** (in the banner or the notification) downloads the right + installer for your OS, verifies it against the release's published SHA-256 checksums, and hands it + to your system installer. On Linux, which ships several package formats, it opens the release page + so you can pick the one you use. The web banner links straight to the release. + +### Changed +- The **Settings** window is now constrained to a 4:3 aspect ratio. + ## 26.06.01 — 2026-06-02 ### Fixed diff --git a/src/searchmob_desktop/cli.py b/src/searchmob_desktop/cli.py index 44ed1a9..530d598 100644 --- a/src/searchmob_desktop/cli.py +++ b/src/searchmob_desktop/cli.py @@ -57,6 +57,7 @@ VersionTag, check_if_due, fetch_latest, + reconcile_pending_update, ) from searchmob_desktop.version import __version__ @@ -252,7 +253,8 @@ def _run_update_check_in_background(prefs_store: JsonPreferencesStore) -> None: """Fire-and-forget the throttled GitHub update check on a thread. Never blocks server startup, never crashes the CLI: a bare `except Exception` swallows - anything bubbling out (network, serialization, prefs IO). Prints an update banner via + anything bubbling out (network, serialization, prefs IO). Persists the throttle stamp and the + pending-update fields (which the served-page banner reads) and prints an update banner via `console` if a newer release is found; otherwise stays silent. """ @@ -268,8 +270,9 @@ def _do() -> None: client_factory=lambda: make_privacy_client(4.0), ) ) - if stamped != prefs.last_update_check_ms: - prefs_store.save(prefs.with_update_check_stamped(stamped)) + reconciled = reconcile_pending_update(prefs, info, stamped=stamped) + if reconciled != prefs: + prefs_store.save(reconciled) if info is not None: _print_update_available(info) except Exception: diff --git a/src/searchmob_desktop/gui/main_window.py b/src/searchmob_desktop/gui/main_window.py index 4732b37..b110d33 100644 --- a/src/searchmob_desktop/gui/main_window.py +++ b/src/searchmob_desktop/gui/main_window.py @@ -10,9 +10,11 @@ from __future__ import annotations import asyncio +import sys import time from html import escape from importlib.resources import as_file, files +from pathlib import Path from PySide6.QtCore import Qt, QThreadPool, QTimer, QUrl, Signal from PySide6.QtGui import QAction, QDesktopServices, QIcon, QKeySequence @@ -42,7 +44,12 @@ save_personalization, ) from searchmob_desktop.data.ranking_store import load_ranking_rules, save_ranking_rules -from searchmob_desktop.engines import EngineContext, SearchResult, aggregate +from searchmob_desktop.engines import ( + EngineContext, + SearchResult, + aggregate, + make_privacy_client, +) from searchmob_desktop.engines.correct import start_background_corrector from searchmob_desktop.engines.local_llm import LlmConfig, stream_answer from searchmob_desktop.engines.rank import ( @@ -71,6 +78,14 @@ from searchmob_desktop.gui.workers import AsyncWorker from searchmob_desktop.prefs import JsonPreferencesStore from searchmob_desktop.server import LOOPBACK_HOST +from searchmob_desktop.update import ( + VersionTag, + check_if_due, + fetch_latest, + reconcile_pending_update, +) +from searchmob_desktop.update_download import default_download_dir, download_and_verify +from searchmob_desktop.version import __version__ def app_icon() -> QIcon: @@ -160,6 +175,13 @@ def __init__( outer.setContentsMargins(20, 16, 20, 12) outer.setSpacing(12) + # "Update available" banner, pinned at the very top (hidden until a check finds a newer + # release). Surfaced from persisted prefs on launch and from the background check below. + self._pending_update: tuple[str, str] | None = None + self._update_notified = False + self._update_banner = self._build_update_banner() + outer.addWidget(self._update_banner) + # Search bar row: a roomy field with a prominent accent-filled action button. search_row = QHBoxLayout() search_row.setSpacing(10) @@ -312,6 +334,11 @@ def __init__( if not prefs.onboarding_completed or prefs.onboarding_version < ONBOARDING_VERSION: QTimer.singleShot(0, self._show_onboarding) + # Surface a pending update found by an earlier check (persisted in prefs). A fresh throttled + # check is kicked off on first show (see showEvent), so it only runs for a real launch. + self._update_check_started = False + self._surface_pending_update_from_prefs(prefs) + def _show_onboarding(self) -> None: from searchmob_desktop.gui.onboarding_dialog import OnboardingDialog @@ -484,6 +511,9 @@ def _setup_tray(self) -> None: tray.setContextMenu(menu) tray.activated.connect(self._on_tray_activated) + # Clicking the "update available" notification starts the update. messageClicked does not + # say which fired, so _on_notification_clicked no-ops unless an update is actually pending. + tray.messageClicked.connect(self._on_notification_clicked) tray.show() self._tray = tray @@ -507,6 +537,179 @@ def _quit_from_tray(self) -> None: self._really_quit = True self.close() + # --- Update notifier --------------------------------------------------------------------- + + def _build_update_banner(self) -> QFrame: + """A dismissible "update available" bar with an Update button and a close affordance.""" + bar = QFrame() + bar.setObjectName("updateBanner") + row = QHBoxLayout(bar) + row.setContentsMargins(14, 8, 8, 8) + row.setSpacing(10) + self._update_label = QLabel("An update is available.") + self._update_btn = QPushButton("Update") + self._update_btn.clicked.connect(self._on_update_clicked) + dismiss = QPushButton("✕") # MULTIPLICATION X, a compact close glyph + dismiss.setProperty("role", "dismiss") + dismiss.setToolTip("Dismiss until the next check") + dismiss.setFixedWidth(34) + dismiss.clicked.connect(self._update_banner_dismiss) + row.addWidget(self._update_label, stretch=1) + row.addWidget(self._update_btn) + row.addWidget(dismiss) + bar.hide() + return bar + + @staticmethod + def _current_version_code() -> int: + parsed = VersionTag.parse(__version__) + return parsed.to_version_code() if parsed is not None else 0 + + def _surface_pending_update_from_prefs(self, prefs: object) -> None: + """Show the banner for a prefs-recorded pending update, if still newer than this build.""" + version = getattr(prefs, "pending_update_version", "") + url = getattr(prefs, "pending_update_url", "") + if not version or not url: + return + parsed = VersionTag.parse(version) + if parsed is None or parsed.to_version_code() <= self._current_version_code(): + return + # Notify on launch too: the app being open with an update available is exactly the case the + # system notification is for. _surface_update guards against repeating it within a session. + self._surface_update(version, url, notify=True) + + def _surface_update(self, version: str, url: str, *, notify: bool) -> None: + """Show/refresh the banner and optionally post a one-per-session system notification.""" + self._pending_update = (version, url) + self._update_label.setText(f"SearchMob {version} is available.") + self._update_btn.setEnabled(True) + self._update_btn.setText("Update") + self._update_banner.show() + if notify and not self._update_notified: + self._update_notified = True + self._post_update_notification(version) + + def _post_update_notification(self, version: str) -> None: + """Post a native system notification through the tray icon, when one is available.""" + if self._tray is not None and QSystemTrayIcon.supportsMessages(): + self._tray.showMessage( + "Update available", + f"SearchMob {version} is available. Click here to update.", + app_icon(), + 8000, + ) + + def _on_notification_clicked(self) -> None: + """The tray notification was clicked: bring the window forward and start the update.""" + if self._pending_update is None: + return + self._show_from_tray() + self._on_update_clicked() + + def _update_banner_dismiss(self) -> None: + # Hide for this session only. The pending record stays in prefs, so the banner returns on + # the next launch or check until the user actually updates. + self._update_banner.hide() + + def showEvent(self, event): # type: ignore[no-untyped-def] + # Kick off the throttled update check the first time the window is shown (a real launch), + # not on construction, so headless/widget tests that never show it stay offline. + super().showEvent(event) + if not self._update_check_started: + self._update_check_started = True + QTimer.singleShot(0, self._start_update_check) + + def _start_update_check(self) -> None: + """Run the throttled GitHub check off-thread; surface a newer release if found.""" + + async def _probe() -> object: + prefs = self._prefs_store.load() + now_ms = int(time.time() * 1000) + info, stamped = await check_if_due( + prefs, + self._current_version_code(), + now_ms=now_ms, + client_factory=lambda: make_privacy_client(4.0), + ) + return prefs, info, stamped + + worker: AsyncWorker[object] = AsyncWorker(_probe) + worker.signals.finished.connect(self._on_update_check_done) + worker.signals.failed.connect(lambda _msg: None) # fail-soft: a check error stays silent + worker.start(self._pool) + + def _on_update_check_done(self, payload: object) -> None: + """Persist the check result (stamp + pending fields) and surface a newer release.""" + if not (isinstance(payload, tuple) and len(payload) == 3): + return + prefs, info, stamped = payload + reconciled = reconcile_pending_update(prefs, info, stamped=stamped) + if reconciled != prefs: + try: + self._prefs_store.save(reconciled) + except OSError: + pass + if info is not None: + self._surface_update(info.latest_version.formatted(), info.release_url, notify=True) + + def _on_update_clicked(self) -> None: + """Fetch-and-hand-off: download + verify the installer, or open the release page.""" + if self._pending_update is None: + return + version, url = self._pending_update + self._update_btn.setEnabled(False) + self._update_label.setText(f"Downloading SearchMob {version} …") + system = sys.platform + + async def _run() -> object: + # A generous read timeout: the installer is large, and httpx applies the timeout per + # network operation (max gap between bytes), not to the whole transfer. + async with make_privacy_client(60.0) as client: + info = await fetch_latest(client) + if info is None: + return ("page", url) + asset = info.asset_for_system(system) + sums = info.checksums_asset() + if asset is None or sums is None: + # Linux (multiple package formats) or a release without a usable asset/checksum: + # hand off to the release page so the user picks the right download. + return ("page", info.release_url) + path = await download_and_verify(client, asset, sums, default_download_dir()) + return ("file", str(path)) + + worker: AsyncWorker[object] = AsyncWorker(_run) + worker.signals.finished.connect( + lambda payload, v=version, u=url: self._on_update_download_done(payload, v, u) + ) + worker.signals.failed.connect( + lambda msg, v=version, u=url: self._on_update_download_failed(msg, v, u) + ) + worker.start(self._pool) + + def _on_update_download_done(self, payload: object, version: str, url: str) -> None: + self._update_btn.setEnabled(True) + self._update_label.setText(f"SearchMob {version} is available.") + kind, value = payload if isinstance(payload, tuple) and len(payload) == 2 else ("page", url) + if kind == "file": + # Hand off to the OS: opening the installer mounts the .dmg / launches the .msi. While + # builds are unsigned, Gatekeeper / SmartScreen will still prompt; that is unavoidable. + if not QDesktopServices.openUrl(QUrl.fromLocalFile(value)): + # Could not auto-open it: reveal the folder so the user can run it themselves. + QDesktopServices.openUrl(QUrl.fromLocalFile(str(Path(value).parent))) + self.statusBar().showMessage(f"Downloaded installer: {value}") + else: + QDesktopServices.openUrl(QUrl(value)) + + def _on_update_download_failed(self, message: str, version: str, url: str) -> None: + self._update_btn.setEnabled(True) + self._update_label.setText(f"SearchMob {version} is available.") + QMessageBox.warning( + self, + "Update download failed", + f"{message}\n\nOpening the release page so you can download it manually.", + ) + QDesktopServices.openUrl(QUrl(url)) + # --- Search ------------------------------------------------------------------------------ def _on_submit(self) -> None: @@ -820,6 +1023,13 @@ def _on_rules_changed() -> None: # The personalization toggle / export-import / reset may have changed in Settings; pick up # the new enabled state and reload the model (so a live toggle takes effect immediately). prefs_after = self._prefs_store.load() + # A "Check now" in Settings may have found (or cleared) a pending update; reflect it without + # re-notifying (the user is already here). Hide the banner if the pending record is gone. + if prefs_after.pending_update_version and prefs_after.pending_update_url: + self._surface_pending_update_from_prefs(prefs_after) + else: + self._pending_update = None + self._update_banner.hide() self._personalization_enabled = prefs_after.personalization_enabled self._personalization = ( load_personalization() diff --git a/src/searchmob_desktop/gui/settings_dialog.py b/src/searchmob_desktop/gui/settings_dialog.py index 9485d81..1ec15ae 100644 --- a/src/searchmob_desktop/gui/settings_dialog.py +++ b/src/searchmob_desktop/gui/settings_dialog.py @@ -67,7 +67,13 @@ from searchmob_desktop.gui.theme import DARK, LIGHT, SYSTEM from searchmob_desktop.gui.workers import AsyncWorker from searchmob_desktop.prefs import JsonPreferencesStore, UserPreferences -from searchmob_desktop.update import RELEASES_PAGE_URL, UpdateInfo, fetch_latest +from searchmob_desktop.update import ( + RELEASES_PAGE_URL, + UpdateInfo, + VersionTag, + fetch_latest, + reconcile_pending_update, +) from searchmob_desktop.version import __version__ if TYPE_CHECKING: @@ -145,7 +151,12 @@ def __init__( super().__init__(parent) self.setWindowTitle("Settings") self.setModal(True) - self.resize(780, 640) + # Constrain the window to a 4:3 aspect ratio: open at 4:3 and keep height locked to 3/4 of + # the width as the user resizes (see resizeEvent). The guard flag prevents the resize we + # make inside resizeEvent from recursing. + self._adjusting_aspect = False + self.setMinimumSize(640, 480) # 4:3 + self.resize(800, 600) # 4:3 self._prefs_store = prefs_store self._server_controller = server_controller @@ -204,6 +215,21 @@ def __init__( # --- Persistence helper ------------------------------------------------------------------ + def resizeEvent(self, event): # type: ignore[no-untyped-def] + # Lock the window to 4:3: after any resize, snap the height to 3/4 of the current width. The + # reentrancy guard stops the resize() below from triggering this handler again in a loop. + super().resizeEvent(event) + if self._adjusting_aspect: + return + width = self.width() + target_height = round(width * 3 / 4) + if abs(target_height - self.height()) > 1: + self._adjusting_aspect = True + try: + self.resize(width, target_height) + finally: + self._adjusting_aspect = False + def _save(self, new_prefs: UserPreferences) -> None: self._prefs = new_prefs try: @@ -1044,8 +1070,6 @@ def _on_toggled(checked: bool) -> None: return tab def _on_check_now(self) -> None: - from searchmob_desktop.update import VersionTag - # One check at a time: disabling the button stops rapid clicks from stacking concurrent # workers (and duplicate result dialogs). Re-enabled when the check finishes or fails. self._check_now_btn.setEnabled(False) @@ -1058,18 +1082,23 @@ async def _probe() -> UpdateInfo | None: def _on_finished(info_obj: object) -> None: self._check_now_btn.setEnabled(True) - stamped = replace(self._prefs, last_update_check_ms=int(time.time() * 1000)) - self._save(stamped) - if not isinstance(info_obj, UpdateInfo): + parsed = VersionTag.parse(__version__) + current = parsed.to_version_code() if parsed else 0 + info = info_obj if isinstance(info_obj, UpdateInfo) else None + # Persist the throttle stamp and the pending-update fields together, so the in-app and + # served-page banners pick up (or clear) the result of this manual check on next read. + newer = info if (info is not None and info.is_newer_than(current)) else None + self._prefs = reconcile_pending_update( + self._prefs, newer, stamped=int(time.time() * 1000) + ) + self._save(self._prefs) + if info is None: QMessageBox.warning( self, "Update check failed", f"Could not reach GitHub. Releases page: {RELEASES_PAGE_URL}", ) return - info: UpdateInfo = info_obj - parsed = VersionTag.parse(__version__) - current = parsed.to_version_code() if parsed else 0 if info.is_newer_than(current): v = info.latest_version QMessageBox.information( diff --git a/src/searchmob_desktop/gui/theme.py b/src/searchmob_desktop/gui/theme.py index 791dc56..37e072a 100644 --- a/src/searchmob_desktop/gui/theme.py +++ b/src/searchmob_desktop/gui/theme.py @@ -197,6 +197,31 @@ def build_qss(p: Palette) -> str: }} QLabel[role="caveat-text"] {{ color: {p.danger_text}; }} +/* "Update available" banner, pinned at the top of the window. Accent fill so it reads as a + notice; the Update button keeps its primary styling against it. */ +QFrame#updateBanner {{ + background-color: {p.accent}; + border: none; + border-radius: 10px; +}} +QFrame#updateBanner QLabel {{ background: transparent; color: {p.on_accent}; font-weight: 600; }} +QFrame#updateBanner QPushButton {{ + background-color: {p.on_accent}; + color: {p.accent}; + border: none; + border-radius: 9px; + padding: 7px 16px; + font-weight: 700; +}} +QFrame#updateBanner QPushButton:hover {{ background-color: {p.card_hover}; }} +QFrame#updateBanner QPushButton[role="dismiss"] {{ + background: transparent; + color: {p.on_accent}; + padding: 6px 10px; + font-weight: 700; +}} +QFrame#updateBanner QPushButton[role="dismiss"]:hover {{ background-color: {p.accent_hover}; }} + /* Contextual Wikipedia summary card, above the results. */ QFrame#summaryCard {{ background-color: {p.card}; diff --git a/src/searchmob_desktop/prefs.py b/src/searchmob_desktop/prefs.py index 556fff9..9155116 100644 --- a/src/searchmob_desktop/prefs.py +++ b/src/searchmob_desktop/prefs.py @@ -88,6 +88,12 @@ class UserPreferences: llm_model: str = "" update_check_enabled: bool = True last_update_check_ms: int = 0 + # The newest published release the last update check found (empty when none / up to date). These + # drive the in-app and served-page "update available" banners and the tray notification so a + # found update survives a restart until a later check supersedes or clears it. Non-secret: just + # a version string and the release URL. Set/cleared via update.reconcile_pending_update. + pending_update_version: str = "" + pending_update_url: str = "" # Set once the first-run setup wizard has been completed or skipped, so it never reappears. onboarding_completed: bool = False # The onboarding revision the user last saw. The wizard re-appears once after an update that diff --git a/src/searchmob_desktop/server/app.py b/src/searchmob_desktop/server/app.py index ada352b..4593155 100644 --- a/src/searchmob_desktop/server/app.py +++ b/src/searchmob_desktop/server/app.py @@ -74,6 +74,8 @@ render_results_page, render_settings_page, ) +from searchmob_desktop.update import VersionTag +from searchmob_desktop.version import __version__ LOOPBACK_HOST = "127.0.0.1" DEFAULT_PORT = 8787 @@ -96,6 +98,12 @@ _SUGGESTIONS_CONTENT_TYPE = "application/x-suggestions+json" _OPENSEARCH_CONTENT_TYPE = "application/opensearchdescription+xml" +# The running app's version code, used to decide whether a persisted pending-update record is still +# newer than what is installed (so the owner-only banner self-clears after an in-place update even +# before the next background check rewrites prefs). 0 if the version string is unparseable. +_parsed_version = VersionTag.parse(__version__) +_CURRENT_VERSION_CODE = _parsed_version.to_version_code() if _parsed_version is not None else 0 + # A source of autocomplete suggestions for a partial query. Implementations MUST be fail-soft: # any error, timeout, or unavailable backing source returns an empty list rather than raising, so @@ -506,6 +514,7 @@ async def home(request: Request) -> Response: settings_link=_is_settings_owner(request), rules=rules_provider(), editable=_is_owner(request), + update_banner=_owner_update_banner(request), ) return Response(body, media_type="text/html; charset=utf-8") @@ -524,6 +533,23 @@ def _is_loopback_request(request: Request) -> bool: client_host = request.client.host if request.client is not None else "" return is_loopback_host(client_host) + def _owner_update_banner(request: Request) -> tuple[str, str] | None: + # The "update available" banner is owner-only: a network visitor cannot install anything and + # should not learn the owner's version. Read live from prefs (set by the background check), + # and only show it when the pending version still parses newer than what is installed. + if not _is_loopback_request(request): + return None + prefs = _load_prefs() + if prefs is None: + return None + version, url = prefs.pending_update_version, prefs.pending_update_url + if not version or not url: + return None + parsed = VersionTag.parse(version) + if parsed is None or parsed.to_version_code() <= _CURRENT_VERSION_CODE: + return None + return version, url + async def _maybe_summary(query: str) -> SummaryBox | None: if summary_provider is None or not query.strip(): return None @@ -581,6 +607,7 @@ async def search_html(request: Request) -> Response: vertical=vertical.value, settings_link=_is_settings_owner(request), link_builder=link_builder, + update_banner=_owner_update_banner(request), ) return Response(body, media_type="text/html; charset=utf-8") diff --git a/src/searchmob_desktop/server/templates.py b/src/searchmob_desktop/server/templates.py index 52a33f9..b4bfe7f 100644 --- a/src/searchmob_desktop/server/templates.py +++ b/src/searchmob_desktop/server/templates.py @@ -75,6 +75,14 @@ ".settings-link:hover{border-color:var(--accent);color:var(--accent)}" ".settings-link+.theme-toggle{margin-left:0}" ".topbar .spacer{margin-left:auto}" + # Owner-only "update available" banner, pinned above the top bar. Accent fill so it reads as a + # notice without an icon set; the action is a high-contrast pill linking to the release. + ".updatebar{display:flex;align-items:center;gap:12px;padding:9px 18px;background:var(--accent);" + "color:#fff;font-size:13px}" + ".updatebar .msg{font-weight:600}" + ".updatebar .btn{margin-left:auto;background:#fff;color:var(--accent);border-radius:16px;" + "padding:5px 14px;font-weight:700;text-decoration:none;white-space:nowrap}" + ".updatebar .btn:hover{text-decoration:none;opacity:.92}" ".settings{max-width:680px;margin:0 auto;padding:24px 18px 60px}" ".settings h1{font-size:24px;margin:8px 0 18px}" ".settings .saved{color:#fff;background:var(--accent);display:inline-block;border-radius:6px;" @@ -252,6 +260,25 @@ def _theme_toggle_button() -> str: ) +def _update_banner(banner: tuple[str, str] | None) -> str: + """An "update available" notice bar linking to the new release. Empty when `banner` is None. + + `banner` is `(version, url)`. The server passes it only for the loopback owner (a network + visitor cannot install anything and should not see the owner's version), so this renderer just + formats it. The link opens the release page; the GUI offers the verified one-click install. + """ + if banner is None: + return "" + version, url = banner + return ( + '
' + f'SearchMob {escape(version)} is available.' + f'' + "Get the update" + "
" + ) + + def _settings_link(show: bool) -> str: """A Settings-page link, shown only to the loopback owner (the route itself is owner-only).""" if not show: @@ -283,19 +310,22 @@ def render_home_page( settings_link: bool = False, rules: RankingRules | None = None, editable: bool = False, + update_banner: tuple[str, str] | None = None, ) -> str: """The home page: a centered search box plus the OpenSearch link. `settings_link` adds a Settings link to the top bar; the server passes True only for the loopback owner, since the Settings route is owner-only. `rules` + `editable` add a scope (lens) selector below the search box for the loopback owner, so a scope can be chosen before searching - (the selector renders only when at least one lens exists). + (the selector renders only when at least one lens exists). `update_banner` is `(version, url)` + for the owner-only "update available" notice (None to omit). """ active_rules = rules if rules is not None else RankingRules() scope = _scope_bar(active_rules) if editable else "" head = _page_head("SearchMob") body = ( '' + f"{_update_banner(update_banner)}" '
' '' f"{_settings_link(settings_link)}" @@ -448,6 +478,7 @@ def render_results_page( vertical: str = "web", settings_link: bool = False, link_builder: Callable[[int, str], str] | None = None, + update_banner: tuple[str, str] | None = None, ) -> str: """The results page. Empty/blank query -> a placeholder; otherwise -> the merged results. @@ -473,6 +504,7 @@ def render_results_page( parts: list[str] = [] parts.append('') + parts.append(_update_banner(update_banner)) parts.append('
') parts.append('') parts.append('