Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
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`.

## 26.06.01 — 2026-06-02

### Fixed
- **Setup wizard now opens centered over the main window** again, with a screen-centered fallback if
the main window is not yet on screen, fixing a 26.06.00 regression where it could open off-center.
- **The "what's new" update prompt is no longer the first thing existing users see.** After an update
that adds a new opt-in setting, the wizard now shows returning users **only** the new feature's page
(with its toggle) so they can review and enable it, instead of replaying the whole first-run setup.
New installs see the personalization opt-in as the **last** step of setup rather than an early one.

## 26.06.00 — 2026-06-01

### Added
Expand Down
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ searchmob-desktop --help # invoke the CLI
- No em dashes (— –) anywhere in source, comments, docs, or UI strings; use plain punctuation.
- Type-annotated where it helps the reader; `mypy --strict` runs in CI.

## Surfacing new opt-in settings to existing users

When a feature adds a new **opt-in setting that users should review and choose** (a privacy or
ranking toggle, a new data-storing option, anything they would want to know exists), make the setup
wizard show it to people who already onboarded, not just fresh installs:

1. Add the feature's page to the wizard (`gui/onboarding_dialog.py`) and bump `ONBOARDING_VERSION`
in the same change.
2. The wizard re-appears **once** for any user whose saved `onboarding_version` is behind, showing
**only** the new feature's page (with its activation toggle) so they can review and enable it.
New installs see the same page as the last step of first-run setup.
3. Keep the toggle **off by default** and persist it the moment it is changed, so nothing is enabled
unless the user actually opts in.

Treat this as part of "done" for any settings-bearing feature, and confirm it during release review.
The Android app mirrors this with `ONBOARDING_VERSION` in `ui/onboarding/OnboardingState.kt`.

## Releases (maintainers)

Releases follow Ubuntu-style `YY.MM.VV` versioning. Bump `__version__` in
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ follow_imports = "skip"
[tool.briefcase]
project_name = "SearchMob Desktop"
bundle = "com.flintwave"
version = "26.06.00"
version = "26.06.01"
url = "https://github.com/FlintWave/SearchMob-Desktop"
author = "FlintWave"
author_email = "flintwave@tuta.com"
Expand Down
56 changes: 32 additions & 24 deletions src/searchmob_desktop/gui/onboarding_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from dataclasses import replace

from PySide6.QtGui import QShowEvent
from PySide6.QtGui import QGuiApplication, QShowEvent
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
Expand Down Expand Up @@ -50,11 +50,13 @@ def __init__(
self._prefs_store = prefs_store
self._server_controller = server_controller
self._prefs = prefs_store.load()
# A returning user (already onboarded once) is being re-shown the wizard after an update, so
# the welcome copy frames it as "what's new" rather than a first-time setup.
# A returning user (already onboarded once) is re-shown the wizard after an update only to
# surface the new opt-in feature, so they get just that page, not the whole setup again.
self._returning = self._prefs.onboarding_completed

self.setWindowTitle("Welcome to SearchMob")
self.setWindowTitle(
"What's new in SearchMob" if self._returning else "Welcome to SearchMob"
)
self.setModal(True)
self.resize(640, 560)

Expand All @@ -72,15 +74,19 @@ def __init__(
top.addWidget(skip)
outer.addLayout(top)

# Build the page list. The service page is included only on platforms that support one, so
# the wizard never shows steps a user cannot act on.
# Build the page list. A returning user (re-onboarded after an update) sees only the new
# feature; a first-run user gets the full setup with the personalization opt-in as the last
# step (not an early interruption). The service page is included only where supported.
self._stack = QStackedWidget(self)
self._stack.addWidget(self._welcome_page())
self._stack.addWidget(self._privacy_page())
self._stack.addWidget(self._personalize_page())
self._stack.addWidget(self._browser_page())
if service.is_supported():
self._stack.addWidget(self._service_page())
if self._returning:
self._stack.addWidget(self._personalize_page())
else:
self._stack.addWidget(self._welcome_page())
self._stack.addWidget(self._privacy_page())
self._stack.addWidget(self._browser_page())
if service.is_supported():
self._stack.addWidget(self._service_page())
self._stack.addWidget(self._personalize_page())
outer.addWidget(self._stack, stretch=1)

nav = QHBoxLayout()
Expand All @@ -101,15 +107,24 @@ def showEvent(self, event: QShowEvent) -> None:
"""Center the wizard over the main window on first show.

A parented modal is not reliably centered by every window manager (it can open in a corner),
so we position it explicitly over the parent's frame the first time it appears.
so we position it explicitly the first time it appears: over the parent's frame when the
parent is actually on screen, otherwise over the active screen as a fallback (the parent may
not be mapped yet when the wizard is shown right after launch).
"""
super().showEvent(event)
if self._centered:
return
self._centered = True
geo = self.frameGeometry()
parent = self.parentWidget()
if not self._centered and parent is not None:
geo = self.frameGeometry()
if parent is not None and parent.isVisible():
geo.moveCenter(parent.frameGeometry().center())
self.move(geo.topLeft())
self._centered = True
else:
screen = self.screen() or QGuiApplication.primaryScreen()
if screen is None:
return
geo.moveCenter(screen.availableGeometry().center())
self.move(geo.topLeft())

# --- Page builders -----------------------------------------------------------------------

Expand All @@ -132,13 +147,6 @@ def _page(heading: str, body: str, extra: QWidget | None = None) -> QWidget:
return page

def _welcome_page(self) -> QWidget:
if self._returning:
return self._page(
"What's new in SearchMob",
"There is a new feature worth a look: SearchMob can now learn from your clicks to "
"personalize ranking, privately and on-device. Step through to turn it on, or skip "
"to keep things as they are. Your existing settings are unchanged.",
)
return self._page(
"Welcome to SearchMob",
"Private, on-device metasearch. Your searches are aggregated from several engines "
Expand Down
2 changes: 1 addition & 1 deletion src/searchmob_desktop/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
# Ubuntu-style scheme matching the Android app: YY.MM.VV (two-digit year,
# month, and per-month build). First desktop release is 26.05.00. Bump
# manually each release; hatchling reads __version__ from this file.
__version__ = "26.06.00"
__version__ = "26.06.01"
15 changes: 15 additions & 0 deletions tests/gui/test_onboarding_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ def test_personalize_page_toggle_persists_immediately(qapp: object, tmp_path: Pa
assert store.load().personalization_enabled is True


def test_returning_user_sees_only_the_new_feature_page(qapp: object, tmp_path: Path) -> None:
# A re-onboarded user (already completed onboarding before the version bump) gets just the
# personalization page so they can activate the new feature, not the whole first-run setup.
from dataclasses import replace

store = _store(tmp_path)
store.save(replace(store.load(), onboarding_completed=True))
dialog = OnboardingDialog(prefs_store=store)
assert dialog._stack.count() == 1
# The single page carries the activation toggle (so they can turn the feature on).
assert dialog._personalize_check is not None
dialog._personalize_check.setChecked(True)
assert store.load().personalization_enabled is True


def test_skip_also_persists_completed_flag(qapp: object, tmp_path: Path) -> None:
# Skip is wired to the same _finish handler; a skipped wizard must not reappear.
store = _store(tmp_path)
Expand Down