Files
voculinux/src/vocalinux/ui/settings_about.py
T
xavierk 4171567fd7
CI / security (push) Successful in 1m12s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped
docs: publish Void xbps as the only install channel, drop upstream references (#38)
User-facing docs, About page and website now point at the fork and the Void xbps repository, with one credit line to upstream Vocalinux. Channels this fork does not publish are removed from navigation.
2026-11-06 16:17:04 +00:00

404 lines
15 KiB
Python

"""About page content and trusted project links for Settings."""
import logging
import os
from typing import Any, Optional
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: E402
from ..utils.packaging import is_packaged_install, updates_description
from ..utils.update_checker import (
DEFAULT_CHECK_AUTOMATICALLY,
build_channel,
is_trusted_release_url,
)
from ..version import __copyright__, __url__, __version__
logger = logging.getLogger(__name__)
VOCULINUX_SITE_URL = "https://voculinux.com"
# The fork's own source, issue and release host. The update checker reads the
# same host, so About and the updater agree on where releases live.
SOURCE_REPO_URL = __url__
SOURCE_ISSUES_URL = f"{SOURCE_REPO_URL}/issues"
# The one upstream credit: the project this fork was made from.
UPSTREAM_REPO_URL = "https://github.com/VocaHQ/vocalinux"
UPSTREAM_CREDIT_LINE = f"Voculinux is a hard fork of Vocalinux by VocaHQ: {UPSTREAM_REPO_URL}"
# About page links that are not release URLs.
_ABOUT_OPEN_URLS = frozenset(
{
VOCULINUX_SITE_URL,
SOURCE_REPO_URL,
SOURCE_ISSUES_URL,
UPSTREAM_REPO_URL,
}
)
# Currentcolor brand marks. fill is currentColor; paint to match the dialog
# foreground so the marks stay visible on both light and dark surfaces.
_ABOUT_INK = "#14231C"
_ABOUT_INK_ON_DARK = "#E6E1D8"
_ABOUT_ICON_TEXT_PX = 18
def _color_luminance(color: Any) -> float:
"""Relative luminance of a Gdk.RGBA (0 = black, 1 = white)."""
return float(0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue)
def _rgba_to_hex(color: Any) -> str:
"""Format a Gdk.RGBA as #rrggbb."""
r = max(0, min(255, int(round(color.red * 255))))
g = max(0, min(255, int(round(color.green * 255))))
b = max(0, min(255, int(round(color.blue * 255))))
return f"#{r:02x}{g:02x}{b:02x}"
def _about_surface_is_dark() -> bool:
"""Return True when GTK is using a dark theme."""
settings = Gtk.Settings.get_default()
if settings is not None:
try:
if bool(settings.get_property("gtk-application-prefer-dark-theme")):
return True
except Exception:
pass
try:
theme = str(settings.get_property("gtk-theme-name") or "").lower()
if "dark" in theme:
return True
except Exception:
pass
try:
from ..utils.gtk_color_scheme import read_os_color_scheme
if read_os_color_scheme() == "prefer-dark":
return True
except Exception:
pass
return False
def _about_ink_hex(
widget: Optional[Gtk.Widget] = None,
dark: Optional[bool] = None,
) -> str:
"""Ink for currentColor marks: theme fg when known, else light/dark defaults."""
if dark is None:
dark = _about_surface_is_dark()
if widget is not None:
ctx = widget.get_style_context()
for name in ("theme_fg_color", "theme_text_color", "fg_color"):
try:
found, color = ctx.lookup_color(name)
except Exception:
continue
if not found:
continue
lum = _color_luminance(color)
if dark and lum >= 0.45:
return _rgba_to_hex(color)
if not dark and lum <= 0.40:
return _ABOUT_INK
return _ABOUT_INK_ON_DARK if dark else _ABOUT_INK
def _can_open_url(url: str) -> bool:
"""Return True for this project's release URLs or the About page allowlist."""
return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url))
class AboutSectionMixin(Gtk.Dialog):
"""Build About content on the Settings dialog."""
def _about_mark_image(
self: Any,
icon_name: str,
pixel_size: int = _ABOUT_ICON_TEXT_PX,
dark: Optional[bool] = None,
) -> Optional[Gtk.Image]:
"""Load a currentColor SVG and paint it to match the dialog foreground."""
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:
ink = _about_ink_hex(self, dark)
with open(path, encoding="utf-8") as handle:
svg = handle.read().replace("currentColor", ink)
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
loader.set_size(pixel_size, pixel_size)
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(pixel_size)
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: Any,
url: str,
tooltip: str,
label: str = "Open",
icon_name: Optional[str] = None,
width: int = 100,
dark: Optional[bool] = None,
) -> Gtk.Button:
"""Button that opens a trusted About URL.
Unique labels (Website, Source code, Report a bug or idea) are the
accessible name. Shared "Open" labels use the tooltip instead.
"""
from .settings_dialog import _set_accessible_name
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, dark=dark)
if image is not None:
image.set_margin_end(8)
button.set_image(image)
button.set_always_show_image(True)
button.get_style_context().add_class("about-mark-button")
button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest))
return button
def _build_about_section(self: Any) -> None:
"""Build the About page using the same PreferenceRow cards as other pages."""
about_tab = getattr(self, "about_tab")
config_manager = getattr(self, "config_manager")
on_check_updates_clicked = getattr(self, "_on_check_updates_clicked")
on_update_checks_toggled = getattr(self, "_on_update_checks_toggled")
on_update_channel_changed = getattr(self, "_on_update_channel_changed")
on_open_release_clicked = getattr(self, "_on_open_release_clicked")
from gi.repository import GdkPixbuf
from ..utils.resource_manager import ResourceManager
from .settings_dialog import (
_CONTROL_WIDTH,
PreferenceRow,
PreferencesGroup,
_combo_chrome,
_combo_with_suffix,
_set_accessible_name,
_style_action_button,
)
dark = _about_surface_is_dark()
about_icon = None
logo_path = ResourceManager().get_icon_path("voculinux")
if os.path.exists(logo_path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path)
scaled = pixbuf.scale_simple(48, 48, GdkPixbuf.InterpType.BILINEAR)
about_icon = Gtk.Image.new_from_pixbuf(scaled)
about_icon.set_pixel_size(48)
except Exception as exc:
logger.warning("Failed to load About icon: %s", exc)
app_group = PreferencesGroup(
title="Voculinux",
description=(
"Voice dictation for Linux (X11 and Wayland). After a model is downloaded, "
"speech is processed on this PC."
),
keywords=("about", "version", "app"),
header_icon=about_icon,
)
self.about_version_label = Gtk.Label(label=__version__)
self.about_version_label.set_selectable(True)
self.about_version_label.get_style_context().add_class("preference-row-subtitle")
version_row = PreferenceRow(
title="Version",
subtitle="Currently installed Voculinux build",
widget=self.about_version_label,
keywords=("version", "build"),
)
app_group.add_row(version_row)
link_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
link_box.pack_start(
self._about_open_button(
VOCULINUX_SITE_URL,
"Open voculinux.com website",
label="Website",
width=110,
dark=dark,
),
False,
False,
0,
)
link_box.pack_start(
self._about_open_button(
SOURCE_REPO_URL,
"Open the Voculinux source repository",
label="Source code",
width=120,
dark=dark,
),
False,
False,
0,
)
links_row = PreferenceRow(
title="Open source",
subtitle="Product site at voculinux.com, source on the project's Gitea host.",
widget=link_box,
keywords=("website", "voculinux.com", "gitea", "source", "repo", "code", "agpl"),
)
app_group.add_row(links_row)
license_row = PreferenceRow(
title="License",
subtitle=f"GNU AGPL v3 · {__copyright__}",
keywords=("license", "agpl", "gpl", "copyright"),
)
app_group.add_row(license_row)
about_tab.pack_start(app_group, False, False, 0)
talk_group = PreferencesGroup(
title="Talk to us",
description=("Bugs, feedback, and feature ideas go to the Voculinux issue tracker."),
keywords=("issues", "bug", "feedback", "contact", "report", "idea"),
)
report_btn = self._about_open_button(
SOURCE_ISSUES_URL,
"Open Voculinux issues for a bug or idea",
label="Report a bug or idea",
width=200,
dark=dark,
)
talk_group.add_row(
PreferenceRow(
title="Issues",
subtitle=SOURCE_ISSUES_URL,
widget=report_btn,
keywords=("issue", "bug", "idea"),
)
)
about_tab.pack_start(talk_group, False, False, 0)
build = build_channel()
updates_group = PreferencesGroup(
title="Updates",
description=updates_description(build, packaged=is_packaged_install()),
keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"),
)
self.update_checks_switch = Gtk.Switch()
self.update_checks_switch.set_active(
config_manager.get_bool("updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY)
)
self.update_checks_switch.connect("state-set", on_update_checks_toggled)
updates_group.add_row(
PreferenceRow(
title="Check for updates automatically",
subtitle=(
"Look for new releases in the background and show a tray entry and a "
"notification when one is out"
),
widget=self.update_checks_switch,
keywords=("update", "notification", "automatic", "check", "disable"),
)
)
self.update_channel_combo = Gtk.ComboBoxText()
self.update_channel_combo.append("stable", "Stable")
self.update_channel_combo.append("nightly", "Nightly")
_combo_chrome(
self.update_channel_combo,
tooltip=(
"Stable uses the latest numbered release. Nightly uses the newest "
"nightly-YYYY-MM-DD build."
),
)
self.update_channel_combo.set_active_id(build_channel())
self.check_updates_btn = Gtk.Button(label="Check")
self.check_updates_btn.get_style_context().add_class("suffix-button")
_style_action_button(self.check_updates_btn, width=80)
self.check_updates_btn.set_tooltip_text(
"Look up the latest Voculinux release for this channel"
)
self.check_updates_btn.connect("clicked", on_check_updates_clicked)
self.update_status_row = PreferenceRow(
title="Channel",
subtitle="Not checked yet",
widget=_combo_with_suffix(
self.update_channel_combo,
self.check_updates_btn,
combo_width=_CONTROL_WIDTH - 88,
),
keywords=("channel", "stable", "nightly", "beta", "status", "check"),
)
updates_group.add_row(self.update_status_row)
about_tab.pack_start(updates_group, False, False, 0)
self.update_channel_combo.connect("changed", on_update_channel_changed)
self.release_notes_group = PreferencesGroup(
title="What's New",
keywords=("changelog", "what's new", "release notes", "latest", "download"),
)
self.open_release_btn = Gtk.Button(label="Open")
_style_action_button(self.open_release_btn)
self.open_release_btn.set_sensitive(False)
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", on_open_release_clicked)
self.release_notes_row = PreferenceRow(
title="Release notes",
subtitle="Notes appear here after a successful update check.",
widget=self.open_release_btn,
keywords=("notes", "changelog", "latest"),
)
self.latest_release_row = self.release_notes_row
if self.release_notes_row.subtitle_label is not None:
self.release_notes_row.subtitle_label.set_max_width_chars(72)
self.release_notes_group.add_row(self.release_notes_row)
about_tab.pack_start(self.release_notes_group, False, False, 0)
self.release_notes_group.hide()
origin_group = PreferencesGroup(
title="Origin",
keywords=("upstream", "fork", "origin", "credit", "project"),
)
upstream_row = PreferenceRow(
title="Upstream project",
subtitle=UPSTREAM_CREDIT_LINE,
widget=self._about_open_button(
UPSTREAM_REPO_URL,
"Open the upstream project repository",
label="Upstream",
width=110,
dark=dark,
),
keywords=("upstream", "fork", "credit"),
)
origin_group.add_row(upstream_row)
about_tab.pack_start(origin_group, False, False, 0)