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.
404 lines
15 KiB
Python
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)
|