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
2 changes: 1 addition & 1 deletion .github/workflows/verify-and-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
version="$(BUILD_DATE="$build_date" python -c 'from scripts.package_addon import build_version; import os; print(build_version(os.environ["BUILD_DATE"], os.environ["RUN_NUMBER"]))')"
label="g${COMMIT_SHA:0:7}"
fi
package_output="$(python scripts/package_addon.py --version "$version" --label "$label")"
package_output="$(python scripts/package_addon.py --version "$version" --label "$label" --commit "$COMMIT_SHA")"
package_name="$(printf '%s\n' "$package_output" | awk -F= '/^PACKAGE_NAME=/{print $2}')"
test -n "$package_name"
echo "$package_output"
Expand Down
1 change: 1 addition & 0 deletions _speech_core/plugin_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"speechHookLoadedMessage": "string(default='ClassicSpeech hook loaded')",
"debugLogging": "boolean(default=False)",
"checkForUpdatesAutomatically": "boolean(default=True)",
"updateChannel": "string(default='stable')",
# Seconds since the epoch of the last successful update check.
"lastUpdateCheck": "integer(default=0)",
"announceMenuOpen": "boolean(default=True)",
Expand Down
18 changes: 16 additions & 2 deletions _speech_core/settings/advanced_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import wx
import logHandler

from ..update_check import automatic_checks_enabled, set_automatic_checks_enabled
from ..update_check import (
automatic_checks_enabled, set_automatic_checks_enabled, preferred_channel, set_preferred_channel,
)
from .accessibility import _set_panel_description
from .advanced_config import (
_get_announce_speech_hook_loaded_enabled,
Expand Down Expand Up @@ -80,11 +82,20 @@ def __init__(self, parent):

self.checkForUpdates = wx.CheckBox(
self,
label=_("Check for ClassicSpeech updates automatically"),
label=_("Check for updates automatically:"),
)
self.checkForUpdates.SetValue(automatic_checks_enabled())
mainSizer.Add(self.checkForUpdates, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)

channelRow = wx.BoxSizer(wx.HORIZONTAL)
channelRow.Add(wx.StaticText(self, label=_("Update from:")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)
self.updateChannel = wx.Choice(self, choices=[_("stable"), _("dev")])
self.updateChannel.SetName(_("Update from:"))
self.updateChannel.SetSelection(1 if preferred_channel() == "dev" else 0)
channelRow.Add(self.updateChannel, 1, wx.EXPAND)
mainSizer.Add(channelRow, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
self.updateChannel.Bind(wx.EVT_CHOICE, self.onChanged)

self.SetSizer(mainSizer)
self._syncSpeechHookLoadedMessageAvailability()
self.debugLogging.Bind(wx.EVT_CHECKBOX, self.onChanged)
Expand Down Expand Up @@ -114,5 +125,8 @@ def apply_live(self, save=True):
checkForUpdates = self.__dict__.get("checkForUpdates")
if checkForUpdates is not None:
set_automatic_checks_enabled(checkForUpdates.GetValue())
channel = self.__dict__.get("updateChannel")
if channel is not None:
set_preferred_channel("dev" if channel.GetSelection() == 1 else "stable")
if save:
return
53 changes: 53 additions & 0 deletions _speech_core/update_channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Public development release identity and installed-build provenance."""
import json
import re
from datetime import datetime
from pathlib import Path

OFFICIAL_REPOSITORY = "trssharp/classicspeech-nvda"
METADATA_PATH = Path(__file__).with_name("build_info.json")
_DEV_VERSION = re.compile(r"[0-9]{8}\.[1-9][0-9]*")


def dev_version(tag):
"""Official tags are dev-YYYYMMDD.RUN, never an arbitrary prerelease."""
if not isinstance(tag, str) or not tag.startswith("dev-"):
return None
version = tag[4:]
if not _DEV_VERSION.fullmatch(version):
return None
try:
datetime.strptime(version[:8], "%Y%m%d")
except ValueError:
return None
return version


def official_dev_assets(data, repository, version):
if repository != OFFICIAL_REPOSITORY:
return False
name = f"ClassicSpeech-{version}.nvda-addon"
prefix = f"https://github.com/{repository}/releases/download/dev-{version}/"
assets = data.get("assets")
if not isinstance(assets, list):
return False
for wanted in (name, name + ".sha256"):
matches = [a for a in assets if isinstance(a, dict) and a.get("name") == wanted]
if len(matches) != 1 or matches[0].get("browser_download_url") != prefix + wanted:
return False
return data.get("html_url") == f"https://github.com/{repository}/releases/tag/dev-{version}"


def installed_channel(version):
"""Metadata describes the installed artifact, never the user's preference.

Old numeric release versions are stable. Old date/run artifacts have no
channel provenance: only a manual check may offer a channel transition.
"""
try:
data = json.loads(METADATA_PATH.read_text(encoding="utf-8"))
if data.get("version") == version and data.get("channel") in ("stable", "dev", "unknown"):
return data["channel"]
except (OSError, ValueError, TypeError, AttributeError):
pass
return "unknown" if re.match(r"^[0-9]{8}\.", version) else "stable"
105 changes: 96 additions & 9 deletions _speech_core/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from dataclasses import dataclass

from .localization import _
from .update_channels import dev_version, official_dev_assets, installed_channel, OFFICIAL_REPOSITORY

API_URL = "https://api.github.com/repos/{repository}/releases/latest"
RELEASES_URL = "https://github.com/{repository}/releases"
Expand Down Expand Up @@ -58,6 +59,7 @@ class Release:
addon_url: str = ""
addon_size: int = 0
checksum_url: str = ""
channel: str = "stable"


# -- pure helpers ---------------------------------------------------------------
Expand Down Expand Up @@ -90,25 +92,34 @@ def is_newer(candidate, installed):
return new + (0,) * (width - len(new)) > old + (0,) * (width - len(old))


def release_from_github(data):
def release_from_github(data, channel="stable", repository=OFFICIAL_REPOSITORY):
"""Return the Release described by GitHub's JSON for a release, or None."""
if not isinstance(data, dict) or data.get("draft") or data.get("prerelease"):
if not isinstance(data, dict) or data.get("draft") or channel not in ("stable", "dev"):
return None
tag = str(data.get("tag_name") or "")
version = tag[1:] if tag[:1] in ("v", "V") else tag
if channel == "dev":
version = dev_version(tag)
if data.get("prerelease") is not True or not version or not official_dev_assets(data, repository, version):
return None
else:
if data.get("prerelease"):
return None
version = tag[1:] if tag[:1] in ("v", "V") else tag
if parse_version(version) is None:
return None
addon, checksum = None, None
assets = [asset for asset in data.get("assets") or () if isinstance(asset, dict)]
for asset in assets:
if str(asset.get("name") or "").lower().endswith(ADDON_EXTENSION):
if (str(asset.get("name") or "").lower().endswith(ADDON_EXTENSION)
and (channel == "stable" or asset.get("name") == f"ClassicSpeech-{version}.nvda-addon")):
addon = asset
break
if addon is not None:
wanted = str(addon.get("name")) + CHECKSUM_EXTENSION
checksum = next((asset for asset in assets if asset.get("name") == wanted), None)
return Release(
version=version,
channel=channel,
name=str(data.get("name") or f"ClassicSpeech {version}"),
notes=str(data.get("body") or ""),
page_url=str(data.get("html_url") or ""),
Expand Down Expand Up @@ -194,8 +205,10 @@ def _headers(version, repository):
}


def fetch_latest_release(version, repository, session=None):
def fetch_latest_release(version, repository, session=None, channel="stable"):
"""Ask GitHub for the latest release. Raises UpdateError."""
if channel == "dev":
return fetch_dev_release(version, repository, session=session)
getter = session or _requests()
try:
response = getter.get(
Expand All @@ -218,6 +231,36 @@ def fetch_latest_release(version, repository, session=None):
return release


def fetch_dev_release(version, repository, session=None):
"""Scan public releases, not authenticated/expiring Actions artifacts."""
if repository != OFFICIAL_REPOSITORY:
raise UpdateError(_("Development updates are available only from the official ClassicSpeech repository."))
getter = session or _requests()
latest = None
# Bound a malicious/never-ending response; never treat a truncated scan as complete.
for page in range(1, 101):
url = f"https://api.github.com/repos/{repository}/releases?per_page=100&page={page}"
try:
response = getter.get(url, headers=_headers(version, repository), timeout=CHECK_TIMEOUT_SECONDS)
except Exception as error:
raise UpdateError(_("GitHub could not be reached. Check your internet connection.")) from error
if response.status_code != 200:
raise UpdateError(_("GitHub answered with error {code}.").format(code=response.status_code))
try:
data = response.json()
if not isinstance(data, list):
raise ValueError("expected release list")
for item in data:
release = release_from_github(item, channel="dev", repository=repository)
if release and (latest is None or is_newer(release.version, latest.version)):
latest = release
except Exception as error:
raise UpdateError(_("GitHub's answer could not be read.")) from error
if len(data) < 100:
return latest
raise UpdateError(_("There are too many release pages to check safely. Please try again later."))


def download_release(release, version, repository, folder, session=None):
"""Download the release's add-on file into ``folder`` and check it. Returns its path.

Expand All @@ -226,6 +269,11 @@ def download_release(release, version, repository, folder, session=None):
"""
if not release.addon_url:
raise UpdateError(_("The release has no add-on file."))
prefix = f"https://github.com/{repository}/releases/download/"
if (not release.addon_url.startswith(prefix)
or (release.checksum_url and not release.checksum_url.startswith(prefix))
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*\.nvda-addon", release.addon_name)):
raise UpdateError(_("The release contains an untrusted download location or file name."))
if not release.checksum_url:
raise UpdateError(_("The release has no checksum file, so its add-on file can't be checked."))
if release.addon_size > MAX_DOWNLOAD_BYTES:
Expand Down Expand Up @@ -275,6 +323,18 @@ def download_release(release, version, repository, folder, session=None):

# -- settings -----------------------------------------------------------------------

def preferred_channel():
from .settings.config_core import _read_classic_speech_section

return "dev" if _read_classic_speech_section().get("updateChannel") == "dev" else "stable"


def set_preferred_channel(channel):
from .settings.config_core import _ensure_classic_speech_section

_ensure_classic_speech_section()["updateChannel"] = "dev" if channel == "dev" else "stable"


def automatic_checks_enabled():
from .settings.config_core import _as_bool, _read_classic_speech_section

Expand Down Expand Up @@ -333,7 +393,7 @@ def schedule_automatic_check(self):

def check(self, manual=True):
self._timer = None
if self._stopped or self._busy:
if self._stopped or self._busy or _is_secure():
return
addon = installed_addon()
if addon is None:
Expand All @@ -355,7 +415,11 @@ def check(self, manual=True):
from .message_priority import speak_message

speak_message(_("Checking for ClassicSpeech updates"))
self._run(lambda: fetch_latest_release(version, repository), lambda outcome: self._checked(outcome, version, repository, manual))
channel = preferred_channel()
self._run(
lambda: fetch_latest_release(version, repository, channel=channel),
lambda outcome: self._checked_current(outcome, version, repository, manual, channel),
)

def _run(self, work, done):
"""Run ``work`` in a background thread, then ``done(result or UpdateError)`` in NVDA's main thread."""
Expand All @@ -373,7 +437,14 @@ def target():

threading.Thread(target=target, name="ClassicSpeechUpdates", daemon=True).start()

def _checked(self, outcome, version, repository, manual):
def _checked_current(self, outcome, version, repository, manual, channel):
# A dialog can change or roll back the preference while the network runs.
if channel != preferred_channel() or (not manual and not automatic_checks_enabled()):
self._busy = False
return
self._checked(outcome, version, repository, manual, channel)

def _checked(self, outcome, version, repository, manual, channel="stable"):
self._busy = False
if self._stopped:
return
Expand All @@ -387,6 +458,17 @@ def _checked(self, outcome, version, repository, manual):
return
_remember_check()
release = outcome
if release is None:
if manual:
self._message(_("No development release has been published yet."))
return
installed = installed_channel(version)
switching = installed != channel
if switching:
if not manual or parse_version(release.version) == parse_version(version):
return
self._offer(release, version, repository, switching=True)
return
if not is_newer(release.version, version):
if manual:
if is_newer(version, release.version):
Expand All @@ -402,13 +484,18 @@ def _checked(self, outcome, version, repository, manual):
return
self._offer(release, version, repository)

def _offer(self, release, version, repository):
def _offer(self, release, version, repository, switching=False):
import wx

summary = _("ClassicSpeech {new} is available. You have version {installed}.").format(
new=release.version,
installed=version,
)
if switching:
summary = _("Switch from installed channel {old} to {new}? You have {installed}; the target is {target}. "
"This may install a numerically lower version. Development builds may be unstable.").format(
old=installed_channel(version), new=release.channel, installed=version, target=release.version,
)
notes = notes_as_text(release.notes)
if not notes:
# Translators: Shown in the What's new box for a release with no notes.
Expand Down
10 changes: 6 additions & 4 deletions doc/en/readme.html
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,8 @@ <h3 id="advanced">Advanced</h3>
<li><strong>Enable ClassicSpeech speech processing hook</strong>: the main switch for ClassicSpeech's speech changes. When cleared, NVDA speech passes through without ClassicSpeech processing, but ClassicSpeech commands still work. Default: checked.</li>
<li><strong>Speak a message when the speech hook loads</strong>, with <strong>Speech hook loaded message</strong>: say a message when ClassicSpeech starts processing speech, for example after NVDA starts. Default: off, "ClassicSpeech hook loaded".</li>
<li><strong>Enable ClassicSpeech diagnostic logging</strong>: write detailed ClassicSpeech entries to the NVDA log. It slows speech, so turn it on only to record a problem, then turn it off. Default: off.</li>
<li><strong>Check for ClassicSpeech updates automatically</strong>: once a day, shortly after NVDA starts, ask GitHub whether a newer ClassicSpeech has been released, and tell you only if there is one. See <a href="#updates">Updating ClassicSpeech</a>. Default: checked.</li>
<li><strong>Check for updates automatically:</strong> once a day, about 30 seconds after NVDA starts, check the selected channel. Uncheck to stop automatic checks only; Check for Updates still works. Default: checked.</li>
<li><strong>Update from:</strong> choose <strong>stable</strong> (default) or <strong>dev</strong> for both manual and automatic checks. Changing this preference never installs anything. Apply and OK accept it; Cancel and Close restore the last accepted settings. See <a href="#updates">Updating ClassicSpeech</a>.</li>
</ul>

<h2 id="web-browse-mode-settings">Web / Browse Mode Settings</h2>
Expand Down Expand Up @@ -591,14 +592,15 @@ <h2 id="updates">Updating ClassicSpeech</h2>
<p>ClassicSpeech can check for a newer version on the GitHub page it comes from: the releases of the page shown as its homepage in NVDA's Add-on Store. A copy of ClassicSpeech without a GitHub homepage can't check. Check for Updates says so, and automatic checks don't run.</p>
<ul>
<li><p>To check now, press <kbd>NVDA+N</kbd>, then choose Preferences, then ClassicSpeech, then <strong>Check for Updates...</strong>. You can also assign a gesture to the <strong>Checks for ClassicSpeech updates</strong> command. You hear "Checking for ClassicSpeech updates", then a message says whether ClassicSpeech is up to date.</p>
<p>The updater checks GitHub's latest published regular release. Draft releases, prereleases and ordinary workflow build artifacts are not offered. It compares version numbers, not the contents of the installed package:</p>
<p><strong>stable</strong> checks GitHub's latest regular release, excluding drafts and prereleases. <strong>dev</strong> checks only official public development prereleases tagged <code>dev-YYYYMMDD.RUN</code> with an add-on and matching SHA-256 asset. It never downloads private or expiring Actions artifacts or requires a GitHub login. If none exist, it says "No development release has been published yet." Within the installed channel, it compares version numbers:</p>
<ul>
<li>If the published version is newer, it offers the update.</li>
<li>If the versions match, it reports that ClassicSpeech is up to date. A changed package published with the same version number is not offered again.</li>
<li>If your installed version is newer, for example a local 2.0 build while GitHub still publishes 1.01, it reports both versions and explains that no update is available. This does not mean your local build has been published.</li>
</ul>
<p>To replace a local build with a published package of the same version, download and open the published add-on file yourself. Date-and-run development builds also compare numerically and may be ahead of regular release versions; install a regular release manually when switching back from such a build.</p></li>
<li>With <strong>Check for ClassicSpeech updates automatically</strong> checked on the <a href="#advanced">Advanced</a> page, ClassicSpeech checks once a day, about 30 seconds after NVDA starts. It says nothing unless there is an update.</li>
<p>To switch channels, choose <strong>Update from:</strong>, accept the settings, then run <strong>Check for Updates...</strong>. A manual channel-switch offer identifies the installed and target channels and versions and warns that the target may be numerically lower. This supports returning from a date-version dev build to stable 2.01. Automatic checks never switch channels. Development builds may be unstable. Choosing a channel alone does not install it: you must accept the download and NVDA's own installation and compatibility checks.</p>
<p>New packages record their installed build channel separately from this preference. Older date/run packages without that metadata are treated as unknown and require a manual channel transition. Equal versions are not offered repeatedly; replacing one with a different package of the same version requires downloading it yourself.</p></li>
<li>With <strong>Check for updates automatically:</strong> checked on the <a href="#advanced">Advanced</a> page, ClassicSpeech checks once a day, about 30 seconds after NVDA starts. It says nothing unless there is an update.</li>
</ul>
<p>When a newer version is available, a dialog opens. It says which version you have and which version is available, and then holds the new release's notes in a box called <strong>What's new</strong>.</p>
<p>That box is a read-only edit box, so the notes are text you can read rather than a message that is spoken once. Focus starts in it, so you can read straight away:</p>
Expand Down
Loading
Loading