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 @@
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.
To check now, press NVDA+N, then choose Preferences, then ClassicSpeech, then Check for Updates.... You can also assign a gesture to the Checks for ClassicSpeech updates command. You hear "Checking for ClassicSpeech updates", then a message says whether ClassicSpeech is up to date.
-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:
+stable checks GitHub's latest regular release, excluding drafts and prereleases. dev checks only official public development prereleases tagged dev-YYYYMMDD.RUN 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:
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.
To switch channels, choose Update from:, accept the settings, then run Check for Updates.... 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.
+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.
+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/