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("