From a37469c8d59a31b91cfa6782191af86eaa922b4a Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sun, 23 Aug 2026 00:50:40 -0700 Subject: [PATCH] feat(ui): About page for this app, VocaHQ, and talk to us (#718) * feat(ui): About page for this app, VocaHQ, and talk to us Settings About now has three groups. Vocalinux keeps the existing icon, version, AGPL-3.0, updater, and What's New. The website button opens vocalinux.com. Part of VocaHQ lists the family sites with PRODUCT.md status. Talk to us opens GitHub issues, Discord, X, and email. Official Design marks from VocaHQ/.github are vendored as-is and painted with iron-white ink. * fix(ui): unique About Open names and pin Design SVG hashes Open buttons on About still say Open. Each one now uses its tooltip as the accessible name so a screen reader can tell vocahq.com from vocalinux.com from Latest Release. The Talk-to-us SVG test pins sha256 of the four official marks from VocaHQ/.github 61c8eee. * fix(ui): circular app icon and About source code link The launcher and About header used the square teal fill. Switch both to the circular family logo (voca-logo.svg). Website stays vocalinux.com; Source code next to it opens the GitHub repo. * fix(appimage): pin pywhispercpp to the version install.sh declares * fix(appimage): pin pywhispercpp at both install sites * test(docs): match AGENTS.md uv.lock wording #727 dropped "unused until those phases land". The test still looked for that line, so any PR that runs Python tests against current main failed. Pin the current contract instead: uv.lock is authoritative, and requirements/*.txt are not hand-edited. --------- Co-authored-by: Sergey Silaev --- packaging/appimage/build.sh | 17 +- resources/icons/scalable/discord.svg | 4 + resources/icons/scalable/github.svg | 4 + resources/icons/scalable/mail.svg | 4 + resources/icons/scalable/vocalinux.svg | 18 +- resources/icons/scalable/x.svg | 4 + .../resources/icons/scalable/discord.svg | 4 + .../resources/icons/scalable/github.svg | 4 + .../resources/icons/scalable/mail.svg | 4 + .../resources/icons/scalable/vocalinux.svg | 18 +- src/vocalinux/resources/icons/scalable/x.svg | 4 + src/vocalinux/ui/settings_dialog.py | 224 +++++++++++++++++- tests/test_appimage_packaging.py | 19 ++ tests/test_installer_review_notes.py | 3 +- tests/test_settings_dialog.py | 189 +++++++++++++++ 15 files changed, 488 insertions(+), 32 deletions(-) create mode 100644 resources/icons/scalable/discord.svg create mode 100644 resources/icons/scalable/github.svg create mode 100644 resources/icons/scalable/mail.svg create mode 100644 resources/icons/scalable/x.svg create mode 100644 src/vocalinux/resources/icons/scalable/discord.svg create mode 100644 src/vocalinux/resources/icons/scalable/github.svg create mode 100644 src/vocalinux/resources/icons/scalable/mail.svg create mode 100644 src/vocalinux/resources/icons/scalable/x.svg diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index 2db7d1e..dd81679 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -28,6 +28,15 @@ WHEEL="$1" VERSION="$2" OUTDIR="${3:-dist}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Same pywhispercpp release install.sh pins. Read from there rather than repeated +# here: unpinned, this build picks up whatever PyPI published today, which is how +# a 1.5.1 released mid-PR broke the Vulkan rebuild. +PYWHISPERCPP_VERSION="$(sed -n 's/^PYWHISPERCPP_VERSION="\([^"]*\)"/\1/p' "$REPO_ROOT/install.sh" | head -n1)" +if [ -z "$PYWHISPERCPP_VERSION" ]; then + echo "Could not read PYWHISPERCPP_VERSION from install.sh" >&2 + exit 1 +fi ARCH="$(uname -m)" PYTHON="${PYTHON:-python3}" @@ -100,8 +109,12 @@ rm -rf "$APPDIR/usr/lib/python${PY_VER}/site-packages" echo "== Installing Vocalinux + runtime deps into the bundle ==" # --ignore-installed: pip otherwise treats the builder env's packages as # satisfying deps and skips copying vosk/pywhispercpp/etc. into AppDir. +# pywhispercpp is named explicitly so this resolves to the pinned release rather +# than the newest inside the wheel's >=1.5.0,<2 range: the Vulkan rebuild below +# installs the pinned one, and two versions' libggml*.so in one AppDir leave +# linuxdeploy chasing a dependency that is not there. "$PYTHON" -m pip install --no-cache-dir --ignore-installed --prefix "$APPDIR/usr" \ - "$WHEEL" PyGObject pycairo onnxruntime + "$WHEEL" "pywhispercpp==$PYWHISPERCPP_VERSION" PyGObject pycairo onnxruntime echo "== Adding desktop entry + icon ==" install -Dm644 "$REPO_ROOT/vocalinux.desktop" "$APPDIR/usr/share/applications/vocalinux.desktop" @@ -240,7 +253,7 @@ rebuild_pywhispercpp_vulkan() { CMAKE_ARGS='-DCMAKE_INSTALL_RPATH=$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON' \ "$PYTHON" -m pip install --verbose --no-cache-dir --force-reinstall \ --no-binary pywhispercpp --ignore-installed --prefix "$APPDIR/usr" \ - --log "$pip_log" pywhispercpp; then + --log "$pip_log" "pywhispercpp==$PYWHISPERCPP_VERSION"; then echo "pywhispercpp Vulkan rebuild failed. Last 80 log lines:" >&2 tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true if [ "$require_vulkan" = "1" ]; then diff --git a/resources/icons/scalable/discord.svg b/resources/icons/scalable/discord.svg new file mode 100644 index 0000000..43868fa --- /dev/null +++ b/resources/icons/scalable/discord.svg @@ -0,0 +1,4 @@ + diff --git a/resources/icons/scalable/github.svg b/resources/icons/scalable/github.svg new file mode 100644 index 0000000..ca42587 --- /dev/null +++ b/resources/icons/scalable/github.svg @@ -0,0 +1,4 @@ + diff --git a/resources/icons/scalable/mail.svg b/resources/icons/scalable/mail.svg new file mode 100644 index 0000000..0ae8952 --- /dev/null +++ b/resources/icons/scalable/mail.svg @@ -0,0 +1,4 @@ + diff --git a/resources/icons/scalable/vocalinux.svg b/resources/icons/scalable/vocalinux.svg index 4e3a76b..5b522ea 100644 --- a/resources/icons/scalable/vocalinux.svg +++ b/resources/icons/scalable/vocalinux.svg @@ -1,11 +1,9 @@ - - - - - - - - - - + + + + + + + + diff --git a/resources/icons/scalable/x.svg b/resources/icons/scalable/x.svg new file mode 100644 index 0000000..843ef66 --- /dev/null +++ b/resources/icons/scalable/x.svg @@ -0,0 +1,4 @@ + diff --git a/src/vocalinux/resources/icons/scalable/discord.svg b/src/vocalinux/resources/icons/scalable/discord.svg new file mode 100644 index 0000000..43868fa --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/discord.svg @@ -0,0 +1,4 @@ + diff --git a/src/vocalinux/resources/icons/scalable/github.svg b/src/vocalinux/resources/icons/scalable/github.svg new file mode 100644 index 0000000..ca42587 --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/github.svg @@ -0,0 +1,4 @@ + diff --git a/src/vocalinux/resources/icons/scalable/mail.svg b/src/vocalinux/resources/icons/scalable/mail.svg new file mode 100644 index 0000000..0ae8952 --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/mail.svg @@ -0,0 +1,4 @@ + diff --git a/src/vocalinux/resources/icons/scalable/vocalinux.svg b/src/vocalinux/resources/icons/scalable/vocalinux.svg index 4e3a76b..5b522ea 100644 --- a/src/vocalinux/resources/icons/scalable/vocalinux.svg +++ b/src/vocalinux/resources/icons/scalable/vocalinux.svg @@ -1,11 +1,9 @@ - - - - - - - - - - + + + + + + + + diff --git a/src/vocalinux/resources/icons/scalable/x.svg b/src/vocalinux/resources/icons/scalable/x.svg new file mode 100644 index 0000000..843ef66 --- /dev/null +++ b/src/vocalinux/resources/icons/scalable/x.svg @@ -0,0 +1,4 @@ + diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 89a3274..97b4c94 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -68,7 +68,7 @@ from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_m from ..utils.whispercpp_model_info import ( list_downloaded_models as list_downloaded_whispercpp_models, ) -from ..version import __copyright__, __description__, __url__, __version__ # noqa: E402 +from ..version import __copyright__, __url__, __version__ # noqa: E402 from .config_manager import ( # noqa: E402 DEFAULT_CONFIG, DEFAULT_SOUND_EFFECT_TONE, @@ -261,6 +261,69 @@ def _default_whispercpp_variant_for_size(model_size: str, language_id: str) -> O # Uniform width for right-hand row controls so they align down a page _CONTROL_WIDTH = 230 +VOCALINUX_SITE_URL = "https://vocalinux.com" +VOCAHQ_SITE_URL = "https://vocahq.com" +VOCAMAC_SITE_URL = "https://vocamac.com" +VOCAPHONE_SITE_URL = "https://vocaphone.vocahq.com" +VOCAGATEWAY_SITE_URL = "https://vocagateway.vocahq.com" +GITHUB_REPO_URL = __url__ +GITHUB_ISSUES_URL = "https://github.com/VocaHQ/vocalinux/issues" +VOCAHQ_DISCORD_URL = "https://discord.gg/UMJduhcqn" +VOCAHQ_X_URL = "https://x.com/vocahq" +VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com" + +# About page links that are not GitHub release URLs. +_ABOUT_OPEN_URLS = frozenset( + { + VOCALINUX_SITE_URL, + VOCAHQ_SITE_URL, + VOCAMAC_SITE_URL, + VOCAPHONE_SITE_URL, + VOCAGATEWAY_SITE_URL, + GITHUB_REPO_URL, + GITHUB_ISSUES_URL, + VOCAHQ_DISCORD_URL, + VOCAHQ_X_URL, + VOCAHQ_MAILTO_URL, + } +) + +# Official Talk-to-us marks from VocaHQ/.github brand/vocahq/social (commit 61c8eee). +# Do not redraw. fill is currentColor; paint iron-white ink at load time. +_ABOUT_INK = "#14231C" +_ABOUT_ICON_TEXT_PX = 16 + +_VOCAHQ_FAMILY_LINKS = ( + (VOCAHQ_SITE_URL, "vocahq.com", "Family site", "Open vocahq.com"), + (VOCALINUX_SITE_URL, "vocalinux.com", "Linux, Available now", "Open vocalinux.com"), + (VOCAMAC_SITE_URL, "vocamac.com", "macOS, Beta", "Open vocamac.com"), + ( + VOCAPHONE_SITE_URL, + "vocaphone.vocahq.com", + "Android beta / iOS source build", + "Open vocaphone.vocahq.com", + ), + ( + VOCAGATEWAY_SITE_URL, + "vocagateway.vocahq.com", + "Optional self-hosted compute", + "Open vocagateway.vocahq.com", + ), +) + + +def _can_open_url(url: str) -> bool: + """Return True for GitHub project URLs or the About page allowlist.""" + return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url)) + + +def _set_accessible_name(widget: Gtk.Widget, name: str) -> None: + """Set the ATK name so identical visible labels stay distinguishable.""" + accessible = widget.get_accessible() + if accessible is not None: + accessible.set_name(name) + + MODEL_SIZE_TOOLTIP = ( "Choose the largest model your computer can run comfortably. Tiny/Base are fastest, " "Small is balanced, and Medium/Large can be more accurate but need more memory." @@ -1571,7 +1634,7 @@ class SettingsDialog(Gtk.Dialog): def _open_web_url(self, url: str) -> None: """Open a trusted project URL in the user's default browser.""" - if not url or not is_trusted_release_url(url): + if not _can_open_url(url): logger.warning("Refusing to open untrusted URL: %s", url) return try: @@ -3360,6 +3423,58 @@ class SettingsDialog(Gtk.Dialog): self.power_user_switch.connect("state-set", self._on_power_user_toggled) + def _about_mark_image(self, icon_name: str) -> Optional[Gtk.Image]: + """Load a Design Talk-to-us SVG and paint currentColor as iron-white ink.""" + from gi.repository import GdkPixbuf + + from ..utils.resource_manager import ResourceManager + + path = ResourceManager().get_icon_path(icon_name) + if not os.path.exists(path): + return None + try: + with open(path, encoding="utf-8") as handle: + svg = handle.read().replace("currentColor", _ABOUT_INK) + loader = GdkPixbuf.PixbufLoader.new_with_type("svg") + loader.set_size(_ABOUT_ICON_TEXT_PX, _ABOUT_ICON_TEXT_PX) + loader.write(svg.encode("utf-8")) + loader.close() + pixbuf = loader.get_pixbuf() + if pixbuf is None: + return None + image = Gtk.Image.new_from_pixbuf(pixbuf) + image.set_pixel_size(_ABOUT_ICON_TEXT_PX) + return image + except Exception as exc: + logger.warning("Failed to load About mark %s: %s", icon_name, exc) + return None + + def _about_open_button( + self, + url: str, + tooltip: str, + label: str = "Open", + icon_name: Optional[str] = None, + width: int = 100, + ) -> Gtk.Button: + """Button that opens a trusted About URL. + + Unique labels (Report a bug or idea, Discord, X, Email) are the + accessible name. Shared "Open" labels use the tooltip instead. + """ + button = Gtk.Button(label=label) + button.set_size_request(width, -1) + button.set_tooltip_text(tooltip) + if label == "Open": + _set_accessible_name(button, tooltip) + if icon_name: + image = self._about_mark_image(icon_name) + if image is not None: + button.set_image(image) + button.set_always_show_image(True) + button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest)) + return button + def _build_about_section(self): """Build the About page using the same PreferenceRow cards as other pages.""" from gi.repository import GdkPixbuf @@ -3379,7 +3494,10 @@ class SettingsDialog(Gtk.Dialog): app_group = PreferencesGroup( title="Vocalinux", - description=__description__, + description=( + "Available now on Linux (X11 and Wayland). After a model is downloaded, " + "speech is processed on this PC." + ), keywords=("about", "version", "app"), header_icon=about_icon, ) @@ -3395,18 +3513,30 @@ class SettingsDialog(Gtk.Dialog): ) app_group.add_row(version_row) - website_btn = Gtk.Button(label="Open") - website_btn.set_size_request(100, -1) - website_btn.set_tooltip_text("Open the Vocalinux GitHub page") - website_btn.connect("clicked", lambda *_: self._open_web_url(__url__)) + website_btn = self._about_open_button( + VOCALINUX_SITE_URL, + "Open vocalinux.com website", + ) website_row = PreferenceRow( title="Website", - subtitle="Source code, issues, and documentation on GitHub", + subtitle="Product site at vocalinux.com", widget=website_btn, - keywords=("github", "website", "repo"), + keywords=("website", "vocalinux.com"), ) app_group.add_row(website_row) + source_btn = self._about_open_button( + GITHUB_REPO_URL, + "Open the Vocalinux GitHub repository", + ) + source_row = PreferenceRow( + title="Source code", + subtitle=GITHUB_REPO_URL, + widget=source_btn, + keywords=("github", "source", "repo", "code"), + ) + app_group.add_row(source_row) + license_row = PreferenceRow( title="License", subtitle=f"GNU AGPL v3 ยท {__copyright__}", @@ -3461,6 +3591,10 @@ class SettingsDialog(Gtk.Dialog): self.open_release_btn.set_tooltip_text( "Open the release page in your browser for download links and install steps" ) + _set_accessible_name( + self.open_release_btn, + "Open the latest release page", + ) self.open_release_btn.connect("clicked", self._on_open_release_clicked) self.latest_release_row = PreferenceRow( title="Latest Release", @@ -3489,6 +3623,78 @@ class SettingsDialog(Gtk.Dialog): self.about_tab.pack_start(self.release_notes_group, False, False, 0) self.release_notes_group.hide() + family_group = PreferencesGroup( + title="Part of VocaHQ", + description=( + "Vocalinux is part of VocaHQ. The same private dictation idea also ships " + "as VocaMac (macOS, beta), VocaWin (Windows, unsigned developer alpha), " + "and VocaPhone (Android beta / iOS source build). VocaGateway is optional " + "self-hosted compute. It is not on-device." + ), + keywords=("vocahq", "vocamac", "vocawin", "vocaphone", "vocagateway", "family"), + ) + for url, title, subtitle, open_name in _VOCAHQ_FAMILY_LINKS: + family_group.add_row( + PreferenceRow( + title=title, + subtitle=subtitle, + widget=self._about_open_button(url, open_name), + keywords=(title, subtitle.lower()), + ) + ) + self.about_tab.pack_start(family_group, False, False, 0) + + talk_group = PreferencesGroup( + title="Talk to us", + description=( + "Bugs, feedback, and feature ideas open a GitHub issue. You pick the " + "template on the next screen." + ), + keywords=("github", "discord", "contact", "bug", "feedback", "email"), + ) + report_btn = self._about_open_button( + GITHUB_ISSUES_URL, + "Open GitHub issues for a bug or idea", + label="Report a bug or idea", + icon_name="github", + width=200, + ) + talk_group.add_row( + PreferenceRow( + title="GitHub issues", + subtitle="https://github.com/VocaHQ/vocalinux/issues", + widget=report_btn, + keywords=("github", "issue", "bug", "idea"), + ) + ) + contact_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + for url, label, tooltip, icon_name in ( + (VOCAHQ_DISCORD_URL, "Discord", "Open the VocaHQ Discord", "discord"), + (VOCAHQ_X_URL, "X", "Open X @vocahq", "x"), + (VOCAHQ_MAILTO_URL, "Email", "Email hello@vocahq.com", "mail"), + ): + contact_box.pack_start( + self._about_open_button( + url, + tooltip, + label=label, + icon_name=icon_name, + width=110, + ), + False, + False, + 0, + ) + talk_group.add_row( + PreferenceRow( + title="Community", + subtitle="Discord, X, and email", + widget=contact_box, + keywords=("discord", "x", "twitter", "email", "mail"), + ) + ) + self.about_tab.pack_start(talk_group, False, False, 0) + def _set_about_update_badge(self, visible: bool) -> None: """Show or hide the green New badge on the About sidebar row.""" about_page = next((page for page in self._pages if page.name == "about"), None) diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py index b93f8ff..d000b2b 100644 --- a/tests/test_appimage_packaging.py +++ b/tests/test_appimage_packaging.py @@ -1,5 +1,6 @@ """Regression guards for AppImage packaging.""" +import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -67,3 +68,21 @@ def test_appimage_build_rebuilds_pywhispercpp_with_vulkan(): assert "rebuild_pywhispercpp_vulkan" in text assert 'CC="${CC:-gcc}"' in text assert 'CXX="${CXX:-g++}"' in text + + +def test_appimage_pins_pywhispercpp_to_the_installer_version(): + """An unpinned source build takes whatever PyPI published that day. + + pywhispercpp 1.5.1 landed mid-review and failed to compile with Vulkan on + the runner, breaking both AppImage jobs while nothing in the repo changed. + """ + build = BUILD_SH.read_text(encoding="utf-8") + # Both installs: the bundle install resolves the wheel's dependency range and + # the Vulkan rebuild replaces it. Pinning one mixes two versions' libggml. + assert build.count("pywhispercpp==$PYWHISPERCPP_VERSION") == 2 + assert 'PYWHISPERCPP_VERSION="$(sed' in build, "the pin must come from install.sh" + + installer = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") + assert re.search( + r'^PYWHISPERCPP_VERSION="\d+\.\d+\.\d+"', installer, re.M + ), "install.sh no longer declares the version build.sh reads" diff --git a/tests/test_installer_review_notes.py b/tests/test_installer_review_notes.py index b17c774..68281ab 100644 --- a/tests/test_installer_review_notes.py +++ b/tests/test_installer_review_notes.py @@ -170,6 +170,7 @@ def test_session_type_env_reads_use_default_expansion() -> None: def test_agents_does_not_claim_requirements_are_consumed() -> None: text = AGENTS.read_text(encoding="utf-8") - assert "are unused until those phases land" in text assert "consumed by `install.sh`" not in text + assert "`uv.lock` is authoritative" in text + assert "Do not edit `requirements/*.txt` by hand" in text assert "https://just.systems" in text diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 7355d28..65a6810 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1020,5 +1020,194 @@ class TestSettingsNavigation(unittest.TestCase): self.assertIn('"escape"', body) +class TestAboutPage(unittest.TestCase): + """About page: this app, VocaHQ family, talk-to-us. Updater stays.""" + + @classmethod + def setUpClass(cls): + cls.source = TestSettingsSearch._settings_source() + cls.about = cls.source.split("def _build_about_section")[1].split("\n def ")[0] + + def test_three_groups_keep_updater(self): + self.assertIn('title="Vocalinux"', self.about) + self.assertIn('title="Part of VocaHQ"', self.about) + self.assertIn('title="Talk to us"', self.about) + self.assertIn('title="Updates"', self.about) + self.assertIn("What's New", self.about) + self.assertIn("_on_check_updates_clicked", self.about) + self.assertIn("self.update_channel_combo", self.about) + + def test_product_status_and_honest_network_copy(self): + self.assertIn("Available now on Linux (X11 and Wayland)", self.about) + self.assertIn("After a model is downloaded", self.about) + self.assertIn("unsigned developer alpha", self.about) + self.assertIn("Android beta / iOS source build", self.about) + self.assertIn("optional", self.about.lower()) + self.assertIn("self-hosted compute", self.about) + self.assertNotIn("SmartScreen", self.about) + self.assertNotIn("100% offline", self.about) + self.assertNotIn("fully offline", self.about) + self.assertNotIn("Coming soon", self.about) + + def test_family_and_talk_urls(self): + for url in ( + "https://vocalinux.com", + "https://vocahq.com", + "https://vocamac.com", + "https://vocaphone.vocahq.com", + "https://vocagateway.vocahq.com", + "https://github.com/VocaHQ/vocalinux/issues", + "https://discord.gg/UMJduhcqn", + "https://x.com/vocahq", + "mailto:hello@vocahq.com", + ): + self.assertIn(url, self.source) + self.assertIn('label="Report a bug or idea"', self.about) + self.assertIn('"Discord"', self.about) + self.assertIn('"X"', self.about) + self.assertIn('"Email"', self.about) + self.assertIn("https://github.com/VocaHQ/vocalinux/issues", self.about) + self.assertIn("https://discord.gg/UMJduhcqn", self.source) + self.assertIn("https://x.com/vocahq", self.source) + self.assertIn("mailto:hello@vocahq.com", self.source) + + def test_open_buttons_have_unique_accessible_names(self): + helper = self.source.split("def _about_open_button")[1].split("\n def ")[0] + self.assertIn("_set_accessible_name(button, tooltip)", helper) + self.assertIn('if label == "Open"', helper) + for name in ( + "Open vocalinux.com website", + "Open vocahq.com", + "Open vocalinux.com", + "Open vocamac.com", + "Open vocaphone.vocahq.com", + "Open vocagateway.vocahq.com", + "Open the Vocalinux GitHub repository", + "Open the latest release page", + ): + self.assertIn(name, self.source) + self.assertIn("_set_accessible_name", self.about) + + def test_website_is_product_site_not_only_github(self): + self.assertIn("VOCALINUX_SITE_URL", self.about) + self.assertIn("Product site at vocalinux.com", self.about) + self.assertNotIn("Open the Vocalinux GitHub page", self.about) + + def test_source_code_row_sits_beside_website(self): + self.assertIn('title="Source code"', self.about) + self.assertIn("GITHUB_REPO_URL", self.about) + self.assertIn("Open the Vocalinux GitHub repository", self.about) + self.assertLess(self.about.find("Website"), self.about.find("Source code")) + + def test_can_open_about_urls(self): + from vocalinux.ui.settings_dialog import ( + GITHUB_ISSUES_URL, + GITHUB_REPO_URL, + VOCAGATEWAY_SITE_URL, + VOCAHQ_DISCORD_URL, + VOCAHQ_MAILTO_URL, + VOCAHQ_SITE_URL, + VOCAHQ_X_URL, + VOCALINUX_SITE_URL, + VOCAMAC_SITE_URL, + VOCAPHONE_SITE_URL, + _can_open_url, + ) + + for url in ( + VOCALINUX_SITE_URL, + VOCAHQ_SITE_URL, + VOCAMAC_SITE_URL, + VOCAPHONE_SITE_URL, + VOCAGATEWAY_SITE_URL, + GITHUB_REPO_URL, + GITHUB_ISSUES_URL, + VOCAHQ_DISCORD_URL, + VOCAHQ_X_URL, + VOCAHQ_MAILTO_URL, + "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0", + ): + self.assertTrue(_can_open_url(url), url) + self.assertFalse(_can_open_url("https://evil.example/payload")) + self.assertFalse(_can_open_url("mailto:evil@example.com")) + self.assertFalse(_can_open_url("")) + + def test_talk_marks_are_official_currentcolor_svgs(self): + import hashlib + from pathlib import Path + + # VocaHQ/.github 61c8eee brand/vocahq/social/{discord,x,github,mail}.svg + expected_sha256 = { + "discord": "195092f3091352d662ceb1e9580877b03a943da0e1ec94a19f6aabac7dbf6dd7", + "x": "0897f679c620010f09771464d39341d91f5a30a1370e126338b9a03bef47df34", + "github": "1c1dd44a826db8b7d589cc48e9e8af2f395e960632c3ad978df8a00b2a56fc36", + "mail": "32fd07b258990f4cba26476fb4f7b4649b9d5e0317000a8a183fa3fe29609972", + } + icons = ( + Path(__file__).resolve().parents[1] + / "src" + / "vocalinux" + / "resources" + / "icons" + / "scalable" + ) + root_icons = Path(__file__).resolve().parents[1] / "resources" / "icons" / "scalable" + for name, title in ( + ("discord", "Discord"), + ("x", "X"), + ("github", "GitHub"), + ("mail", "Email"), + ): + packaged_path = icons / f"{name}.svg" + shipped_path = root_icons / f"{name}.svg" + packaged = packaged_path.read_text(encoding="utf-8") + shipped = shipped_path.read_text(encoding="utf-8") + self.assertEqual(packaged, shipped) + self.assertEqual( + hashlib.sha256(packaged_path.read_bytes()).hexdigest(), + expected_sha256[name], + name, + ) + self.assertEqual( + hashlib.sha256(shipped_path.read_bytes()).hexdigest(), + expected_sha256[name], + name, + ) + self.assertIn('viewBox="0 0 24 24"', packaged) + self.assertIn('fill="currentColor"', packaged) + self.assertIn(f"{title}", packaged) + self.assertNotIn("#5865F2", packaged) + self.assertNotIn("web-browser-symbolic", self.about) + self.assertNotIn("mail-send-symbolic", self.about) + + def test_launcher_icon_is_circular_family_logo(self): + """App/About icon is the approved teal circle lockup, not the square fill.""" + from pathlib import Path + + packaged = ( + Path(__file__).resolve().parents[1] + / "src" + / "vocalinux" + / "resources" + / "icons" + / "scalable" + / "vocalinux.svg" + ) + shipped = ( + Path(__file__).resolve().parents[1] + / "resources" + / "icons" + / "scalable" + / "vocalinux.svg" + ) + packaged_svg = packaged.read_text(encoding="utf-8") + shipped_svg = shipped.read_text(encoding="utf-8") + self.assertEqual(packaged_svg, shipped_svg) + self.assertIn('', packaged_svg) + self.assertIn('aria-label="Vocalinux"', packaged_svg) + self.assertNotIn("