From 27271bcfc341493e75acb39f48065ab4fe926468 Mon Sep 17 00:00:00 2001 From: Tim <273502835+trssharp@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:03:06 -0500 Subject: [PATCH] release: prepare updater-only ClassicSpeech 2.01 --- .github/workflows/verify-and-package.yml | 2 +- _speech_core/plugin_config.py | 1 + _speech_core/settings/advanced_panel.py | 18 +- _speech_core/update_channels.py | 53 ++++ _speech_core/update_check.py | 105 ++++++- doc/en/readme.html | 10 +- docs/RELEASE-2.01.md | 19 ++ manifest.ini | 40 +-- scripts/package_addon.py | 26 +- tests/classic_speech_packaging_harness.py | 28 +- .../classic_speech_update_channels_harness.py | 272 ++++++++++++++++++ 11 files changed, 519 insertions(+), 55 deletions(-) create mode 100644 _speech_core/update_channels.py create mode 100644 docs/RELEASE-2.01.md create mode 100644 tests/classic_speech_update_channels_harness.py diff --git a/.github/workflows/verify-and-package.yml b/.github/workflows/verify-and-package.yml index d0b6454..6ad05ea 100644 --- a/.github/workflows/verify-and-package.yml +++ b/.github/workflows/verify-and-package.yml @@ -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" diff --git a/_speech_core/plugin_config.py b/_speech_core/plugin_config.py index 150045b..1477fc6 100644 --- a/_speech_core/plugin_config.py +++ b/_speech_core/plugin_config.py @@ -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)", diff --git a/_speech_core/settings/advanced_panel.py b/_speech_core/settings/advanced_panel.py index 8520e50..fb7a2a5 100644 --- a/_speech_core/settings/advanced_panel.py +++ b/_speech_core/settings/advanced_panel.py @@ -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, @@ -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) @@ -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 diff --git a/_speech_core/update_channels.py b/_speech_core/update_channels.py new file mode 100644 index 0000000..d4d9617 --- /dev/null +++ b/_speech_core/update_channels.py @@ -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" diff --git a/_speech_core/update_check.py b/_speech_core/update_check.py index 56a5538..770bd51 100644 --- a/_speech_core/update_check.py +++ b/_speech_core/update_check.py @@ -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" @@ -58,6 +59,7 @@ class Release: addon_url: str = "" addon_size: int = 0 checksum_url: str = "" + channel: str = "stable" # -- pure helpers --------------------------------------------------------------- @@ -90,18 +92,26 @@ 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: @@ -109,6 +119,7 @@ def release_from_github(data): 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 ""), @@ -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( @@ -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. @@ -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: @@ -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 @@ -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: @@ -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.""" @@ -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 @@ -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): @@ -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. diff --git a/doc/en/readme.html b/doc/en/readme.html index c53fbf8..54ba46b 100644 --- a/doc/en/readme.html +++ b/doc/en/readme.html @@ -297,7 +297,8 @@

Advanced

  • Enable ClassicSpeech speech processing hook: the main switch for ClassicSpeech's speech changes. When cleared, NVDA speech passes through without ClassicSpeech processing, but ClassicSpeech commands still work. Default: checked.
  • Speak a message when the speech hook loads, with Speech hook loaded message: say a message when ClassicSpeech starts processing speech, for example after NVDA starts. Default: off, "ClassicSpeech hook loaded".
  • Enable ClassicSpeech diagnostic logging: 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.
  • -
  • Check for ClassicSpeech updates automatically: 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 Updating ClassicSpeech. Default: checked.
  • +
  • Check for updates automatically: 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.
  • +
  • Update from: choose stable (default) or dev 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 Updating ClassicSpeech.
  • Web / Browse Mode Settings

    @@ -591,14 +592,15 @@

    Updating ClassicSpeech

    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.

    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 What's new.

    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:

    diff --git a/docs/RELEASE-2.01.md b/docs/RELEASE-2.01.md new file mode 100644 index 0000000..e9c5d93 --- /dev/null +++ b/docs/RELEASE-2.01.md @@ -0,0 +1,19 @@ +# ClassicSpeech 2.01 + +## What's new + +- Choose **stable** or **dev** in General Settings > Advanced > **Update from:**. Stable remains the default. Both manual and automatic checks use this preference. +- **Check for updates automatically:** remains enabled by default and checks about 30 seconds after startup, at most once a day. Turning it off does not disable the manual Check for Updates command. +- Development updates come from official public GitHub prereleases, with an add-on and matching SHA-256 file. No GitHub login is needed. If no development release exists, ClassicSpeech says so instead of offering stable as a fallback. +- To switch channels, accept the settings and run **Check for Updates**. A separate confirmation identifies the installed and target versions and channels, including a return from a numerically higher development build to stable. Automatic checks never switch channels, and changing the preference never installs anything. +- Apply and OK accept the update preferences; Cancel and Close restore the last accepted settings. Packages record their source commit and installed build channel separately from the selected update preference. + +## Scope + +This is an updater-only release based on ClassicSpeech 2.0. Speech processing, voice routing, toast announcements, default-button behavior, diagnostic logging, settings-list Enter behavior, and position-number formatting are unchanged from the stable baseline. + +## Installation and verification + +Download `ClassicSpeech-2.01.nvda-addon` and its `.sha256` sidecar from this release. The sidecar contains the package checksum. Opening the add-on uses NVDA's own installation and compatibility confirmation; this release does not bypass those checks. + +The manifest changelog contains the What's new section above. Automated regression tests cover channel defaults, discovery, preference persistence and dialog transactions, and confirmed channel switching. These checks do not replace live NVDA keyboard, speech, or installation acceptance testing. diff --git a/manifest.ini b/manifest.ini index b26cef9..cac3151 100644 --- a/manifest.ini +++ b/manifest.ini @@ -7,38 +7,8 @@ url = "https://github.com/trssharp/classicspeech-nvda" minimumNVDAVersion = 2025.1 lastTestedNVDAVersion = 2026.2 docFileName = readme.html -changelog = """### Speech and navigation fixes - -* Speech in Windows Explorer and Open and Save dialogs no longer repeatedly searches for a default button on each file-list focus change. ClassicSpeech also uses NVDA's cached focus ancestors to reduce speech-processing delays. -* Tabbing into an edit field reads its current line, or "blank" when it is empty. **Read edit field contents when focused**, on the Verbosity page, can turn this off separately for each verbosity profile. Address bars also keep their name and contents when NVDA announces a tool bar immediately before them. -* Selecting or unselecting the focused item with **Control+Space** announces "selected" or "not selected", regardless of the **List item state reporting** choice. Announcements while moving between items still follow that choice. -* The keyboard-layout menu that opens while adding an Input Gesture now reads its initial item. Stale, held speech fragments no longer suppress the new menu announcement. -* Speech and Sound Schemes tree items named after roles or states, such as "menu" and "selected", are read instead of being mistaken for speech tokens and dropped. -* **NVDA+E** and **Announce default button in dialogs** identify the dialog's real default button more accurately, including split buttons, rather than assuming the focused button is the default. When the default cannot be determined, NVDA+E can explain what Enter presses or identify a uniquely highlighted button by appearance. The appearance check runs only on request, not during ordinary focus speech; with Screen Curtain enabled, it asks you to turn the curtain off before that check. - -### Voice Profiles and Speech and Sound Schemes - -* **Voice Profiles** adds **Mouse**, for physical mouse tracking, and **Document and web formatting**, for voices on formatting and web elements. Use settings supported by the active synthesizer, or choose another synthesizer for an item. Switching synthesizers can add noticeable delay. -* **Speech and Sound Schemes** lets you assign WAV sounds, voices, or both to object types, states, window classes, document formatting and web elements. Sounds can accompany or replace spoken announcements. Named schemes, search, a customized-items filter, custom entries and unassigned switching commands help organize the settings. -* Formatting and element voices now work across character, word, line, sentence, paragraph and Say All reading, including LibreOffice. The required formatting is requested without changing your saved Document Formatting choices or adding unwanted spoken formatting. Fixes also preserve voices across element text, support synthesizers with limited inline voice commands, and make object-state and style/color items effective. Replacing an announcement with a sound does not remove the document text. -* Each scheme keeps its settings and copied WAV files in its own folder. **Open schemes folder**, **Export scheme** and **Import scheme** let you locate and share schemes. Voice Profiles also has export and import controls. Existing schemes migrate to the folder format, and custom fonts, sizes, styles and window classes belong to their individual scheme. -* **Reset this item** in Document and web formatting removes the item's voice without deleting its scheme sound. -* The **NVDA sounds** scheme category can replace NVDA's own sounds, including browse/focus mode, suggestions, spelling errors, screen curtain, Remote Access, startup and exit sounds, without modifying NVDA's waves folder. Removing a replacement restores the native sound. Failed replacement startup, shutdown and Windows sign-out sounds fall back safely to NVDA's own files, and sound-handler cleanup preserves other add-ons' handlers. - -### Settings and message controls - -* ClassicSpeech stores its settings separately in `ClassicSpeech/settings.ini` under NVDA's configuration folder, migrating the previous settings automatically. It backs up the NVDA settings it changes so **Reset All ClassicSpeech Settings** and uninstall can restore them, while preserving subsequent changes you made in NVDA. Updating the add-on keeps ClassicSpeech settings. -* Reset confirmation and update dialogs no longer open inside NVDA's core speech/event pump, fixing the reset freeze. Reset asks for confirmation with **No** as the default. -* Cancelling Web / Browse Mode settings restores the dialog's changes correctly. NVDA settings changed while a Voice Profile speaks are retained, and temporary scheme/voice settings no longer leak into saved synthesizer settings. Voice Profile triggers pause in settings dialogs, apart from explicit scheme previews. -* The Hotkeys options **Speak hotkeys in** and **Which shortcuts to speak** use NVDA's accessible checklists instead of combination-based combo boxes. Menus and Dialogs, and Access keys and Command shortcuts, can each be selected independently. Existing settings remain compatible. ClassicSpeech's other checklists use the same native accessible control. -* **Give ClassicSpeech messages priority over NVDA speech**, on the Misc page, is a new optional setting, off by default. When enabled, it protects ClassicSpeech announcements from automatic interruptions; keyboard, braille and touch input can still stop them. It covers messages such as hook loaded, page ready, page summaries and speech-history replay. - -### Help, updates and Spanish translation - -* A bundled user guide opens from the Add-on Store's **Help** action. An unassigned **Opens the ClassicSpeech user guide** command is also available in Input Gestures. The guide covers the new settings and commands. -* **Check for Updates...** checks this repository's GitHub releases. Automatic checking is enabled by default and runs once a day after startup; it can be turned off on the Advanced page. Checks do not run on secure screens. Installation verifies the downloaded add-on against its published SHA-256 file and uses NVDA's own installation confirmation. -* Manual update checks distinguish an installed development build from GitHub's latest published release: if your installed version is newer, the message reports both versions instead of calling the local build the latest release. The user guide explains draft/prerelease exclusions, numeric version comparisons and same-version manual installation. -* Update offers show the full release notes in a read-only, multiline **What's new** box. You can navigate, select and copy the text with the keyboard; Tab reaches the buttons and Escape closes the dialog without installing. -* On NVDA 2026.1 and later, the Add-on Store's **What's new** action can display the changelog bundled in the add-on manifest. Older supported NVDA versions ignore that field. Packaging checks that it matches the release notes. -* The Spanish catalog now fills previously untranslated entries for recent features. Follow-up corrections improve meaning, NVDA terminology, examples, translation context and scheme-dialog access keys; the compiled catalog is updated too. Spanish remains open to feedback from fluent speakers and NVDA users. -* Josh Kennedy is credited alongside Tim and Calli in the add-on manifest. A runtime compatibility fix removes an unavailable Python-module dependency that otherwise prevented the folder-based schemes version from loading in NVDA.""" +changelog = """- Choose **stable** or **dev** in General Settings > Advanced > **Update from:**. Stable remains the default. Both manual and automatic checks use this preference. +- **Check for updates automatically:** remains enabled by default and checks about 30 seconds after startup, at most once a day. Turning it off does not disable the manual Check for Updates command. +- Development updates come from official public GitHub prereleases, with an add-on and matching SHA-256 file. No GitHub login is needed. If no development release exists, ClassicSpeech says so instead of offering stable as a fallback. +- To switch channels, accept the settings and run **Check for Updates**. A separate confirmation identifies the installed and target versions and channels, including a return from a numerically higher development build to stable. Automatic checks never switch channels, and changing the preference never installs anything. +- Apply and OK accept the update preferences; Cancel and Close restore the last accepted settings. Packages record their source commit and installed build channel separately from the selected update preference.""" diff --git a/scripts/package_addon.py b/scripts/package_addon.py index 412e973..443a3b6 100644 --- a/scripts/package_addon.py +++ b/scripts/package_addon.py @@ -3,6 +3,7 @@ import argparse import hashlib +import json import re import zipfile from datetime import datetime @@ -18,7 +19,7 @@ # NVDA's Add-on Store Help opens doc// from the add-on root. DOC_DIRECTORIES = ("doc",) LOCALE_DIRECTORIES = ("locale",) -RELEASE_NOTES = "RELEASE-2.0.md" +RELEASE_NOTES = "RELEASE-2.01.md" # NVDA 2026.1 and later show the manifest's changelog, rendered from Markdown, when you choose # "What's new" for an add-on in the Add-on Store. Every release's changelog is this section of # its release notes; --sync-changelog copies it into manifest.ini. @@ -53,6 +54,8 @@ def _parse_args() -> argparse.Namespace: default="", help="Optional safe build label, normally g followed by the commit short SHA.", ) + parser.add_argument("--channel", choices=("auto", "stable", "dev"), default="auto") + parser.add_argument("--commit", default="", help="Full source commit SHA (required for dev).") return parser.parse_args() @@ -67,6 +70,21 @@ def build_version(utc_date: str, run_number: str) -> str: return f"{day}.{int(run_number)}" +def build_metadata(version: str, channel: str = "auto", commit: str = "") -> dict: + """Generate artifact provenance, independent of persisted update preference.""" + if channel == "auto": + channel = "unknown" if _CI_BUILD_VERSION.fullmatch(version) else "stable" + if channel not in ("stable", "dev", "unknown"): + raise ValueError("Invalid build channel") + if commit and not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ValueError("Commit must be a full lowercase SHA") + if channel == "dev": + if not re.fullmatch(r"[0-9]{8}\.[1-9][0-9]*", version) or not commit: + raise ValueError("Development packages require a date/run version and full commit SHA") + datetime.strptime(version[:8], "%Y%m%d") + return {"version": version, "channel": channel, "commit": commit} + + def package_filename(version: str, label: str = "") -> str: """Build the public package filename from generated version and commit label.""" if not _NUMERIC_VERSION.fullmatch(version): @@ -227,6 +245,9 @@ def main() -> None: print(f"CHANGELOG={'updated' if changed else 'unchanged'} from docs/{RELEASE_NOTES}") return version = args.version + metadata = build_metadata(version, args.channel, args.commit) + if args.channel == "dev" and args.label: + raise SystemExit("Development asset names must not have a label") package_name = package_filename(version, args.label) try: check_release_changelog(manifest.read_text(encoding="utf-8"), release_notes, version) @@ -241,6 +262,7 @@ def main() -> None: with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr("manifest.ini", manifest_with_version(manifest, version)) + archive.writestr("globalPlugins/_speech_core/build_info.json", json.dumps(metadata, sort_keys=True) + "\n") for relative_path in RUNTIME_FILES: source = ROOT / relative_path if not source.is_file(): @@ -284,6 +306,8 @@ def main() -> None: "globalPlugins/classicSpeech.py", "globalPlugins/_speech_core/nvda_settings_backup.py", "globalPlugins/_speech_core/settings_file.py", + "globalPlugins/_speech_core/build_info.json", + "globalPlugins/_speech_core/update_channels.py", "globalPlugins/_speech_core/processors/web/page_entry.py", "globalPlugins/_speech_core/settings/text/__init__.py", "globalPlugins/_speech_core/settings/text/config.py", diff --git a/tests/classic_speech_packaging_harness.py b/tests/classic_speech_packaging_harness.py index bdef206..92da9bf 100644 --- a/tests/classic_speech_packaging_harness.py +++ b/tests/classic_speech_packaging_harness.py @@ -84,6 +84,28 @@ def test_package_filename_uses_generated_date_and_run_version(self): "ClassicSpeech-4.0.0.nvda-addon", ) + def test_release_archive_has_full_commit_and_stable_provenance(self): + import json + commit = "abcdef0123456789" * 2 + "abcdef01" + version = self.packager.release_notes_version(self.packager.RELEASE_NOTES) + original = (ROOT / "manifest.ini").read_bytes() + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) + with mock.patch.object(self.packager, "DIST", output), mock.patch.object( + sys, "argv", [str(SCRIPT), "--version", version, "--commit", commit] + ): + self.packager.main() + with zipfile.ZipFile(output / f"ClassicSpeech-{version}.nvda-addon") as archive: + self.assertEqual(json.loads(archive.read("globalPlugins/_speech_core/build_info.json")), + {"version": version, "channel": "stable", "commit": commit}) + self.assertEqual((ROOT / "manifest.ini").read_bytes(), original) + workflow = (ROOT / ".github/workflows/verify-and-package.yml").read_text(encoding="utf-8") + self.assertIn('--commit "$COMMIT_SHA"', workflow) + for invalid in ("abc", "A" * 40, "x" * 40): + with self.assertRaises(ValueError): + self.packager.build_metadata(version, commit=invalid) + self.assertEqual(self.packager.build_metadata("20260928.1")["channel"], "unknown") + def test_invalid_version_or_label_is_rejected(self): with self.assertRaises(ValueError): self.packager.package_filename("4.0-edge-notifications") @@ -95,7 +117,7 @@ def test_invalid_version_or_label_is_rejected(self): def test_package_keeps_page_entry_runtime_inside_web_processors(self): with tempfile.TemporaryDirectory() as temporary_directory: output_directory = Path(temporary_directory) - arguments = SimpleNamespace(version="20260728.1", label="layout", sync_changelog=False) + arguments = SimpleNamespace(version="20260728.1", label="layout", sync_changelog=False, channel="auto", commit="") with mock.patch.object(self.packager, "DIST", output_directory), mock.patch.object( self.packager, "_parse_args", return_value=arguments ): @@ -127,7 +149,7 @@ def test_package_keeps_page_entry_runtime_inside_web_processors(self): def test_package_contains_the_user_guide_nvda_opens(self): with tempfile.TemporaryDirectory() as temporary_directory: output_directory = Path(temporary_directory) - arguments = SimpleNamespace(version="20260918.1", label="guide", sync_changelog=False) + arguments = SimpleNamespace(version="20260918.1", label="guide", sync_changelog=False, channel="auto", commit="") with mock.patch.object(self.packager, "DIST", output_directory), mock.patch.object( self.packager, "_parse_args", return_value=arguments ): @@ -196,7 +218,7 @@ def test_nvda_reads_the_changelog_exactly_as_written(self): def test_package_manifest_keeps_the_whats_new(self): with tempfile.TemporaryDirectory() as temporary_directory: output_directory = Path(temporary_directory) - arguments = SimpleNamespace(version="20260920.1", label="notes", sync_changelog=False) + arguments = SimpleNamespace(version="20260920.1", label="notes", sync_changelog=False, channel="auto", commit="") with mock.patch.object(self.packager, "DIST", output_directory), mock.patch.object( self.packager, "_parse_args", return_value=arguments ): diff --git a/tests/classic_speech_update_channels_harness.py b/tests/classic_speech_update_channels_harness.py new file mode 100644 index 0000000..93a62b3 --- /dev/null +++ b/tests/classic_speech_update_channels_harness.py @@ -0,0 +1,272 @@ +"""Channel policy, public discovery and native settings regressions; no network.""" +import importlib +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +import classic_speech_update_check_harness as baseline +from classic_speech_update_check_harness import FakeResponse, FakeSession, _github_release + +ROOT = Path(__file__).resolve().parents[1] +REPO = "trssharp/classicspeech-nvda" + + +def dev_release(version="20260928.1", **extra): + tag = "dev-" + version + name = f"ClassicSpeech-{version}.nvda-addon" + prefix = f"https://github.com/{REPO}/releases/download/{tag}/" + data = _github_release(tag=tag, prerelease=True, draft=False) + data["assets"] = [ + {"name": name, "browser_download_url": prefix + name, "size": 16}, + {"name": name + ".sha256", "browser_download_url": prefix + name + ".sha256"}, + ] + data.update(extra) + return data + + +class ChannelTests(unittest.TestCase): + setUp = baseline.UpdateCheckTests.setUp + tearDown = baseline.UpdateCheckTests.tearDown + _checker = baseline.UpdateCheckTests._checker + + def test_preference_defaults_and_normalization(self): + self.assertEqual(self.updates.preferred_channel(), "stable") + for value, expected in [("dev", "dev"), ("stable", "stable"), ("nightly", "stable"), (None, "stable")]: + self.updates.set_preferred_channel(value) + self.assertEqual(self.updates.preferred_channel(), expected) + + def test_dev_release_contract(self): + parse = self.updates.release_from_github + good = dev_release() + release = parse(good, channel="dev", repository=REPO) + self.assertEqual((release.version, release.channel), ("20260928.1", "dev")) + self.assertIsNone(parse(good)) + for extra in [dict(prerelease=False), dict(draft=True), dict(tag_name="nightly"), + dict(tag_name="dev-20260230.1"), dict(tag_name="dev-20260928.0"), dict(assets=[])]: + self.assertIsNone(parse(dev_release(**extra), channel="dev", repository=REPO)) + self.assertIsNone(parse(good, channel="dev", repository="attacker/fork")) + good["assets"][0]["browser_download_url"] = "https://evil.example/addon" + self.assertIsNone(parse(good, channel="dev", repository=REPO)) + + def test_dev_paginates_and_selects_newest_not_first(self): + url = f"https://api.github.com/repos/{REPO}/releases?per_page=100&page=" + session = FakeSession({ + url + "1": FakeResponse(data=[_github_release()] * 99 + [dev_release("20260928.1")]), + url + "2": FakeResponse(data=[dev_release("20260928.2"), dev_release("20260927.9")]), + }) + result = self.updates.fetch_latest_release("2.0", REPO, session=session, channel="dev") + self.assertEqual(result.version, "20260928.2") + self.assertEqual(len(session.requests), 2) + self.assertTrue(all("Authorization" not in kwargs["headers"] for _, kwargs in session.requests)) + + def test_empty_dev_is_honest_and_errors_do_not_fall_back(self): + url = f"https://api.github.com/repos/{REPO}/releases?per_page=100&page=1" + result = self.updates.fetch_latest_release("2.0", REPO, session=FakeSession({url: FakeResponse(data=[])}), channel="dev") + self.assertIsNone(result) + checker = self._checker() + checker._checked(None, "2.0", REPO, manual=True, channel="dev") + self.assertIn("No development release", checker.messages[-1]) + for response in [FakeResponse(status_code=401), FakeResponse(data={}), OSError("offline")]: + with self.assertRaises(self.updates.UpdateError): + self.updates.fetch_latest_release("2.0", REPO, session=FakeSession({url: response}), channel="dev") + + def test_switch_to_lower_stable_is_manual_and_explicit(self): + release = self.updates.release_from_github(_github_release(tag="v2.0")) + checker = self._checker() + with patch.object(self.updates, "installed_channel", return_value="dev"): + checker._checked(release, "20260928.1", REPO, manual=False) + self.assertEqual(checker.offers, []) + checker._checked(release, "20260928.1", REPO, manual=True) + self.assertEqual(len(checker.offers), 1) + self.assertIn("Switch", checker.offers[0][0]) + self.assertIn("lower", checker.offers[0][0]) + self.assertEqual(checker.downloads, []) + checker._show_offer = lambda *a, **kw: self.wx.ID_YES + with patch.object(self.updates, "installed_channel", return_value="dev"): + checker._checked(release, "20260928.1", REPO, manual=True) + self.assertEqual(checker.downloads, [release.version]) + + def test_same_channel_never_offers_equal_or_older(self): + release = self.updates.release_from_github(dev_release(), channel="dev", repository=REPO) + with patch.object(self.updates, "installed_channel", return_value="dev"): + for manual in (False, True): + for version in ("20260928.1", "20260928.2"): + checker = self._checker() + checker._checked(release, version, REPO, manual=manual, channel="dev") + self.assertEqual(checker.offers, []) + + def test_metadata_is_version_bound_and_legacy_dates_unknown(self): + channels = importlib.import_module("globalPlugins._speech_core.update_channels") + path = Path(self.folder) / "build_info.json" + with patch.object(channels, "METADATA_PATH", path): + self.assertEqual(self.updates.installed_channel("2.0"), "stable") + self.assertEqual(self.updates.installed_channel("20260928.1"), "unknown") + path.write_text(json.dumps({"channel": "dev", "version": "20260928.1"}), encoding="utf-8") + self.assertEqual(self.updates.installed_channel("20260928.1"), "dev") + self.assertEqual(self.updates.installed_channel("20260929.1"), "unknown") + + def test_manual_and_automatic_use_preference_but_opt_out_stops_only_auto(self): + self.updates.set_preferred_channel("dev") + checker = self._checker() + calls = [] + with patch.object(self.updates, "installed_addon", return_value=("2.0", REPO)), patch.object( + self.updates, "fetch_latest_release", side_effect=lambda *a, **kw: calls.append(kw["channel"]) + ): + checker._run = lambda work, done: (work(), setattr(checker, "_busy", False)) + checker.check(manual=False) + self.updates.set_automatic_checks_enabled(False) + checker.check(manual=False) + checker.check(manual=True) + self.assertEqual(calls, ["dev", "dev"]) + + def test_stale_or_stopped_callback_never_offers(self): + checker = self._checker() + self.updates.set_preferred_channel("dev") + checker._checked_current(self.release, "1.0", REPO, True, "stable") + self.assertEqual(checker.offers, []) + checker.stop() + checker._checked(self.release, "1.0", REPO, True) + self.assertEqual(checker.offers, []) + + def test_automatic_delay_due_and_disabled_timer_callback(self): + checker = self._checker() + scheduled = [] + with patch.object(self.updates, "installed_addon", return_value=("2.0", REPO)), patch.object( + self.wx, "CallLater", side_effect=lambda *a, **kw: scheduled.append((a, kw)), create=True + ): + checker.schedule_automatic_check() + self.assertEqual(scheduled[0][0][0], 30000) + self.updates.set_automatic_checks_enabled(False) + checker._run = lambda *args: self.fail("disabled timer reached network") + scheduled[0][0][1](**scheduled[0][1]) + self.updates.set_automatic_checks_enabled(True) + self.updates._remember_check() + checker.schedule_automatic_check() + self.assertEqual(len(scheduled), 1) + + def test_stable_latest_rejects_draft_and_prerelease(self): + url = self.updates.API_URL.format(repository=REPO) + for flags in [dict(prerelease=True), dict(draft=True)]: + with self.assertRaises(self.updates.UpdateError): + self.updates.fetch_latest_release("2.0", REPO, session=FakeSession({url: FakeResponse(data=_github_release(**flags))})) + + def test_unknown_legacy_date_does_not_auto_switch_or_repeat_same_build(self): + checker = self._checker() + release = self.updates.release_from_github(dev_release(), channel="dev", repository=REPO) + checker._checked(release, release.version, REPO, True, "dev") + checker._checked(release, "20260927.1", REPO, False, "dev") + self.assertEqual(checker.offers, []) + checker._checked(release, "20260927.1", REPO, True, "dev") + self.assertIn("unknown", checker.offers[0][0]) + + def test_cross_channel_install_requires_affirmative_response(self): + checker = self._checker() + self.updates.set_preferred_channel("dev") + release = self.updates.release_from_github(dev_release(), channel="dev", repository=REPO) + checker._checked(release, "2.0", REPO, True, "dev") + self.assertEqual(checker.downloads, []) + checker._show_offer = lambda *a, **kw: self.wx.ID_YES + checker._checked(release, "2.0", REPO, True, "dev") + self.assertEqual(checker.downloads, [release.version]) + + def test_download_rejects_foreign_source_and_unsafe_names_before_network(self): + from dataclasses import replace + for fields in [dict(addon_url="https://evil.example/addon"), + dict(checksum_url="http://github.com/trssharp/classicspeech-nvda/sha"), + dict(addon_name="../escape.nvda-addon"), dict(addon_name="C:\\escape.nvda-addon")]: + session = FakeSession({}) + with self.assertRaises(self.updates.UpdateError): + self.updates.download_release(replace(self.release, **fields), "1.0", REPO, self.folder, session) + self.assertEqual(session.requests, []) + + def test_channel_settings_survive_real_configobj_save_reload(self): + import config + from configobj import ConfigObj + from types import SimpleNamespace + settings = importlib.import_module("globalPlugins._speech_core.settings_file") + backup = importlib.import_module("globalPlugins._speech_core.nvda_settings_backup") + base = ConfigObj() + base.filename = str(Path(self.folder) / "nvda.ini") + conf = SimpleNamespace(profiles=[base]) + with patch.object(backup, "_CONFIG_FOLDER_OVERRIDE", self.folder), patch.object(config, "conf", conf): + settings.load_into_nvda(conf) + self.updates.set_preferred_channel("dev") + self.updates.set_automatic_checks_enabled(False) + base.write() + self.assertNotIn(b"updateChannel", Path(base.filename).read_bytes()) + reloaded = ConfigObj(base.filename, encoding="utf-8") + conf.profiles = [reloaded] + settings.load_into_nvda(conf) + self.assertEqual(self.updates.preferred_channel(), "dev") + self.assertFalse(self.updates.automatic_checks_enabled()) + + def test_ok_and_close_commit_and_restore_channel_controls(self): + module = importlib.import_module("globalPlugins._speech_core.settings.dialog") + dialog = object.__new__(module.ClassicSpeechDialog) + dialog._popupReleased = False + dialog._committed = False + dialog._initializeDialogTransaction() + panel = dialog.advancedPanel = module.AdvancedPanel(None) + dialog._saveTransaction = lambda: panel.apply_live() + dialog._clearDirty = lambda: None + panel.updateChannel.GetSelection = lambda: 1 + panel.checkForUpdates.SetValue(False) + panel.onChanged() + dialog.onOK(None) + self.assertEqual(self.updates.preferred_channel(), "dev") + self.assertFalse(self.updates.automatic_checks_enabled()) + panel.updateChannel.GetSelection = lambda: 0 + panel.checkForUpdates.SetValue(True) + panel.onChanged() + skipped = [] + from types import SimpleNamespace + dialog.onClose(SimpleNamespace(Skip=lambda: skipped.append(True))) + self.assertEqual(skipped, [True]) + self.assertEqual(self.updates.preferred_channel(), "dev") + self.assertFalse(self.updates.automatic_checks_enabled()) + + def test_native_advanced_controls_and_transaction(self): + module = importlib.import_module("globalPlugins._speech_core.settings.dialog") + dialog = object.__new__(module.ClassicSpeechDialog) + dialog._popupReleased = False + dialog._committed = False + dialog._initializeDialogTransaction() + dialog.advancedPanel = module.AdvancedPanel(None) + dialog._saveTransaction = lambda: dialog.advancedPanel.apply_live() + dialog._clearDirty = lambda: None + panel = dialog.advancedPanel + self.assertEqual(panel.checkForUpdates.ctorKwargs["label"], "Check for updates automatically:") + self.assertEqual(panel.updateChannel.GetSelection(), 0) + panel.checkForUpdates.SetValue(False) + panel.updateChannel.SetSelection(1) + panel.updateChannel.GetSelection = lambda: 1 + panel.onChanged() + self.assertFalse(self.updates.automatic_checks_enabled()) + self.assertEqual(self.updates.preferred_channel(), "dev") + dialog.onCancel(None) + self.assertTrue(self.updates.automatic_checks_enabled()) + self.assertEqual(self.updates.preferred_channel(), "stable") + dialog = object.__new__(module.ClassicSpeechDialog) + dialog._popupReleased = False + dialog._committed = False + dialog._initializeDialogTransaction() + dialog.advancedPanel = module.AdvancedPanel(None) + dialog._saveTransaction = lambda: dialog.advancedPanel.apply_live() + dialog._clearDirty = lambda: None + panel = dialog.advancedPanel + panel.updateChannel.SetSelection(1) + panel.updateChannel.GetSelection = lambda: 1 + panel.onChanged() + self.assertTrue(dialog.onApply(None)) + panel.updateChannel.SetSelection(0) + panel.updateChannel.GetSelection = lambda: 0 + panel.onChanged() + self.assertEqual(self.updates.preferred_channel(), "stable") + dialog.onCancel(None) + self.assertEqual(self.updates.preferred_channel(), "dev") + + +if __name__ == "__main__": + unittest.main()