diff --git a/README.md b/README.md index c195318..fe36166 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ Meetings are not supported there yet; the details are in the two global shortcuts, whose keys are its two arguments, or the ones already in your settings when it is given none. `./scripts/update.sh` pulls and puts all of that back; `./scripts/uninstall.sh` takes it away again and leaves your settings -and dictations alone unless you pass `--purge`. +and dictations alone unless you pass `--purge`. Dikte looks at the releases page +once a day and puts a line in the tray menu when a newer version is out, which +opens the page rather than installing anything; the General tab turns that off +or runs it on the spot. Speech to text and cleanup each pick a provider in the settings window, and both run here by default, on models of your own. The cloud is the other option: @@ -130,6 +133,7 @@ set next to it. | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Settings | Tray menu → *Settings*, or `dikte settings` | +| Look for a newer version | General tab → *Check now*, or `dikte update` | | Reload after an update | Tray menu → *Restart*, or `dikte restart` | | Quit | Tray menu → *Quit*, or `dikte quit` | @@ -240,6 +244,7 @@ api.py transcription and cleanup requests (stdlib only) cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving hub.py what GitHub and Hugging Face have on offer today +update.py whether a newer release is out, and the page it is on worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index 65a50d1..5e88c69 100644 --- a/README.tr.md +++ b/README.tr.md @@ -104,7 +104,9 @@ toplantı kaydı henüz yok, ayrıntılar [Windows README](README.windows.md)'si başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge` -demedikçe ayarlarına ve diktelerine dokunmaz. +demedikçe ayarlarına ve diktelerine dokunmaz. Dikte sürüm sayfasına günde bir +kez bakar ve yeni sürüm çıkmışsa tepsi menüsüne bir satır koyar; o satır bir şey +kurmaz, sayfayı açar. Genel sekmesi bu denetimi kapatır ya da anında çalıştırır. Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu @@ -127,6 +129,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin. | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` | +| Yeni sürüm var mı bak | Genel sekmesi → *Şimdi bak*, ya da `dikte update` | | Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` | | Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` | @@ -234,6 +237,7 @@ api.py transkript ve temizleme istekleri (yalnız stdlib) cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma hub.py GitHub ve Hugging Face'te bugün ne olduğu +update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/dikte/app.py b/dikte/app.py index 842ee51..1da104e 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -33,8 +33,9 @@ if sys.platform == "darwin": os.environ.get("PATH", "")) if part ) -from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402 -from PyQt6.QtGui import QAction, QIcon # noqa: E402 +from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402 + QUrl, pyqtSignal) +from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402 from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402 from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 @@ -44,11 +45,13 @@ from . import cli # noqa: E402 from . import config as cfg # noqa: E402 from . import ggml # noqa: E402 from . import hotkey # noqa: E402 +from . import hub # noqa: E402 from . import i18n # noqa: E402 from . import integrate # noqa: E402 from . import ipc # noqa: E402 from . import meeting # noqa: E402 from . import trayicon # noqa: E402 +from . import update # noqa: E402 from .i18n import t # noqa: E402 from .meeting import MeetingPipeline # noqa: E402 from .overlay import Overlay # noqa: E402 @@ -77,6 +80,37 @@ ECHO_MS = 2000 # short enough not to sit in the corner for the rest of the hour. PEEK_MS = 12000 +# When the releases page is looked at, and how often it is thought about after +# that. The delay is there so that a check never shares the first seconds of a +# start with the model being loaded and the desktop drawing the tray; the +# interval is not the interval between checks, which update.py holds at a day, +# but how often that clock is read, so that a machine left running for a week +# still asks once a day rather than once a boot. +UPDATE_DELAY_MS = 20000 +UPDATE_POLL_MS = 3 * 3600 * 1000 + + +class UpdateCheck(QObject): + """One look at the releases page, off the interface thread. + + An object of its own because the application is not one: a plain thread + cannot touch a widget, and a signal is the only way back onto the thread + that may. + """ + + # The newer release, or None when there is nothing to say, and the reason + # nothing could be found out instead. + done = pyqtSignal(object, str) + + def start(self): + def work(): + try: + self.done.emit(update.check(), "") + except hub.HubError as exc: + self.done.emit(None, str(exc)) + + threading.Thread(target=work, daemon=True).start() + class Dikte: def __init__(self, app): @@ -159,6 +193,17 @@ class Dikte: self.meeting_ticker.setInterval(500) self.meeting_ticker.timeout.connect(self._meeting_tick) + # What the last check found, read from disk rather than asked for, so + # that a tray built in the next line already knows to say so. + self.update_release = update.pending() + self.updates = UpdateCheck() + self.updates.done.connect(self._on_update_checked) + self.update_ticker = QTimer() + self.update_ticker.setInterval(UPDATE_POLL_MS) + self.update_ticker.timeout.connect(self._look_for_update) + self.update_ticker.start() + QTimer.singleShot(UPDATE_DELAY_MS, self._look_for_update) + self.tray = QSystemTrayIcon() self._apply_settings() self.tray.show() @@ -211,6 +256,11 @@ class Dikte: self.menu.addAction(self.meeting_cancel_action) self.menu.addSeparator() + # Named in _refresh_update, and hidden until a check has found one. + self.update_action = QAction("", self.menu) + self.update_action.triggered.connect(self.open_release_page) + self.menu.addAction(self.update_action) + self.settings_action = QAction(t("Settings…"), self.menu) self.settings_action.triggered.connect(self.open_settings) self.menu.addAction(self.settings_action) @@ -227,6 +277,7 @@ class Dikte: self.tray.setContextMenu(self.menu) self.tray.setToolTip(t("Dikte: ready")) self.tray.activated.connect(self._tray_clicked) + self._refresh_update() self._set_icon("audio-input-microphone") def _tray_clicked(self, reason): @@ -901,12 +952,58 @@ class Dikte: if len(message) > len(first_line): self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000) + # ---- updates ---------------------------------------------------------- + + def _look_for_update(self): + """The timer. update.py decides whether this is a request or a memory.""" + if not self.conf["update_check"]: + return + self.updates.start() + + def _on_update_checked(self, release, error): + if error: + # Nobody asked for this, so nobody is waiting to be told it failed. + # A machine that is offline, or a GitHub that is rate-limiting the + # address, is not a thing to interrupt a dictation about. + print(f"dikte: update check: {error}", file=sys.stderr) + return + if release is None: + return + self._found_update(release) + # Once per version. A check that runs every day must not be a + # notification every day for an update somebody has decided to skip. + if update.announced() != release.version: + update.mark_announced(release.version) + self.tray.showMessage( + "Dikte", + t("Dikte {version} is out. The tray menu has the release page.", + version=release.version), + QSystemTrayIcon.MessageIcon.Information, 8000, + ) + + def _found_update(self, release): + self.update_release = release + self._refresh_update() + + def _refresh_update(self): + release = self.update_release + self.update_action.setVisible(release is not None) + if release is not None: + self.update_action.setText( + t("Dikte {version} is out…", version=release.version)) + + def open_release_page(self): + release = self.update_release + QDesktopServices.openUrl( + QUrl(release.url if release is not None else update.RELEASES_PAGE)) + # ---- settings --------------------------------------------------------- def open_settings(self): if self.settings_window is None: self.settings_window = SettingsWindow(self.conf, self.meetings) self.settings_window.applied.connect(self._apply_settings) + self.settings_window.update_found.connect(self._found_update) self.settings_window.finished.connect(self._settings_closed) self.settings_window.show() self.settings_window.raise_() diff --git a/dikte/cli.py b/dikte/cli.py index 8bfcf56..3109348 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -20,6 +20,7 @@ import signal import subprocess import sys import time +import webbrowser from PyQt6.QtCore import QCoreApplication, QTimer @@ -30,10 +31,12 @@ from . import cleanup from . import config as cfg from . import filetranscribe from . import hotkey +from . import hub from . import ipc from . import integrate from . import meeting from . import paste +from . import update from . import __version__ NOT_RUNNING = 3 @@ -797,6 +800,33 @@ def cmd_integrate(opts): f"{verb}:\n{listing}" if paths else "Nothing to change.") +def cmd_update(opts): + """Whether a newer Dikte has been released, and where it is. + + It looks and nothing more: what to do about the answer is a download page, + because the AppImage, the disk image, the Windows setup and a checkout are + four different installations and only their owner knows which one this is. + """ + try: + release = update.latest(refresh=True) + except hub.HubError as exc: + return fail(opts, exc) + # Written down even when there is nothing new, so that the application does + # not go and ask the same question an hour later. + update.remember(release) + waiting = update.newer(release.version) + payload = {"ok": True, "current": __version__, "latest": release.version, + "update": waiting, "url": release.url} + if not waiting: + return out(opts, payload, + f"Dikte {__version__} is the newest release.") + if opts.open: + webbrowser.open(release.url) + return out(opts, payload, + f"Dikte {release.version} is out; this is {__version__}.\n" + f"{release.url}") + + def cmd_status(opts): reply = ipc.send("status") if reply is None: @@ -1098,6 +1128,11 @@ def build_parser(): integrated.set_defaults(func=cmd_integrate) # --- the application -------------------------------------------------- + updates = leaf(subs, "update", "whether a newer Dikte has been released") + updates.add_argument("--open", action="store_true", + help="open the release page in a browser") + updates.set_defaults(func=cmd_update) + leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status) for name, help_text in (("settings", "open the settings window"), ("restart", "reload the running instance"), diff --git a/dikte/config.py b/dikte/config.py index 7229553..fb7683a 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -457,6 +457,9 @@ DEFAULTS = { "overlay_corner": "bottom-left", "keep_audio": False, "history_limit": 200, + # A look at the releases page once a day, and nothing more than a look: + # what is found opens a browser, never an installer. + "update_check": True, "file_timestamps": False, "file_cleanup": True, "file_cleanup_prompt": "", # empty -> language-specific default diff --git a/dikte/hub.py b/dikte/hub.py index afd8133..9e6e518 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -136,6 +136,22 @@ def release(repo, tag="latest", refresh=False): return data.get("tag_name") or tag, assets +def newest_release(repo, refresh=False): + """(tag, page, published) for the newest release of a repository. + + release() above is for taking a file out of one and insists on there being + files to take; this is for the number, which a release with nothing + attached answers just as well. GitHub keeps prereleases out of "latest" on + its own, which is what leaves the nightly build off this answer. + """ + data = _fetch(f"gh-newest-{repo}", + f"{GITHUB_API}/repos/{repo}/releases/latest", refresh=refresh) + if not isinstance(data, dict) or not data.get("tag_name"): + raise HubError(t("{repo} has published no release.", repo=repo)) + return (data["tag_name"], data.get("html_url") or "", + data.get("published_at") or "") + + def files(repo, revision="main", refresh=False): """[Item] for every file in a Hugging Face repository. diff --git a/dikte/i18n.py b/dikte/i18n.py index 398f740..f7f4910 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -192,6 +192,25 @@ TR = { "Silence threshold": "Sessizlik eşiği", "Keep audio files ({path})": "Ses kayıtlarını sakla ({path})", + # --- updates -------------------------------------------------------- + "Updates": "Güncelleme", + "Look for a newer version once a day": "Günde bir kez yeni sürüm var mı diye bak", + "Dikte only looks. What it finds opens the release page in your browser; " + "it downloads and installs nothing by itself.": + "Dikte yalnızca bakar. Bulduğu şey tarayıcında sürüm sayfasını açar; " + "kendi başına hiçbir şey indirmez ve kurmaz.", + "Check now": "Şimdi bak", + "Looking…": "Bakılıyor…", + "Open the release page": "Sürüm sayfasını aç", + "This is Dikte {version}.": "Buradaki sürüm Dikte {version}.", + "Dikte {version} is the newest release.": "En yeni sürüm zaten bu: Dikte {version}.", + "Dikte {version} is out; this is {current}.": + "Dikte {version} çıkmış; buradaki sürüm {current}.", + "Dikte {version} is out…": "Dikte {version} çıkmış…", + "Dikte {version} is out. The tray menu has the release page.": + "Dikte {version} çıkmış. Sürüm sayfası tepsi menüsünde.", + "{repo} has published no release.": "{repo} için yayımlanmış sürüm yok.", + # --- settings: api -------------------------------------------------- "Keys": "Anahtarlar", "Speech to text": "Sesi yazıya çevirme", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 4563d1a..d3a97b7 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -13,6 +13,7 @@ from PyQt6.QtWidgets import ( QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, ) +from . import __version__ from . import api from . import assistant from . import audio @@ -21,9 +22,11 @@ from . import config as cfg from . import filetranscribe from . import ggml from . import hotkey +from . import hub from . import ipc from . import meeting from . import paste +from . import update from .filetranscribe import FileTranscriber from .i18n import t @@ -512,11 +515,16 @@ class LocalModelBox(QGroupBox): class SettingsWindow(QDialog): applied = pyqtSignal() + # A newer release this window's own check found, so that the tray icon + # hears about it from here rather than waiting for its own next check. + update_found = pyqtSignal(object) _models_loaded = pyqtSignal(list, str) _transcribe_models_loaded = pyqtSignal(list, str) # Which key was tested, whether it worked, and what to write under it. _test_done = pyqtSignal(str, bool, str) + # The release that was found, or None, and what went wrong instead. + _update_checked = pyqtSignal(object, str) def __init__(self, conf, meetings=None, parent=None): super().__init__(parent) @@ -533,6 +541,9 @@ class SettingsWindow(QDialog): self._key_fields = {} self._testers = {} self._shown_provider = "" + # Where "Open the release page" goes: the release itself once a check + # has named one, and the page that redirects to the newest until then. + self._release_url = update.RELEASES_PAGE self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) @@ -567,6 +578,7 @@ class SettingsWindow(QDialog): self._models_loaded.connect(self._on_models_loaded) self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded) self._test_done.connect(self._on_test_done) + self._update_checked.connect(self._on_update_checked) self.transcriber.progress.connect(self._on_file_progress) self.transcriber.finished.connect(self._on_file_finished) self.transcriber.failed.connect(self._on_file_failed) @@ -698,6 +710,23 @@ class SettingsWindow(QDialog): t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR)) ) form.addRow("", self.keep_audio) + + self.update_check = QCheckBox(t("Look for a newer version once a day")) + self.update_check.setToolTip( + t("Dikte only looks. What it finds opens the release page in your " + "browser; it downloads and installs nothing by itself.") + ) + form.addRow(t("Updates"), self.update_check) + + self.update_status = WrappedLabel("") + self.update_page = QPushButton(t("Open the release page")) + self.update_page.clicked.connect( + lambda: QDesktopServices.openUrl(QUrl(self._release_url)) + ) + self.update_now = QPushButton(t("Check now")) + self.update_now.clicked.connect(self._check_for_update) + form.addRow("", self._row(self.update_status, self.update_page, + self.update_now)) return page def _api_tab(self): @@ -1568,6 +1597,8 @@ class SettingsWindow(QDialog): self.silence_db.setValue(int(conf["silence_db"])) self.filter_hallucinations.setChecked(conf["filter_hallucinations"]) self.keep_audio.setChecked(conf["keep_audio"]) + self.update_check.setChecked(conf["update_check"]) + self._show_update(update.pending()) for name, who in cfg.TRANSCRIBERS.items(): self._key_fields[name].setText(conf[who.key]) @@ -1661,6 +1692,7 @@ class SettingsWindow(QDialog): conf["silence_db"] = float(self.silence_db.value()) conf["filter_hallucinations"] = self.filter_hallucinations.isChecked() conf["keep_audio"] = self.keep_audio.isChecked() + conf["update_check"] = self.update_check.isChecked() provider = self.transcribe_provider.currentData() or "local" if provider in self._models: @@ -1891,6 +1923,45 @@ class SettingsWindow(QDialog): button.setEnabled(True) answer.setText(("✓ " if ok else "✗ ") + message) + # ---- updates --------------------------------------------------------- + + def _check_for_update(self): + """The button, which asks GitHub whatever the daily clock says.""" + self.update_now.setEnabled(False) + self.update_status.setText(t("Looking…")) + + def work(): + try: + self._update_checked.emit(update.check(force=True), "") + except hub.HubError as exc: + self._update_checked.emit(None, str(exc)) + + threading.Thread(target=work, daemon=True).start() + + def _on_update_checked(self, release, error): + self.update_now.setEnabled(True) + if error: + self.update_status.setText(error) + return + self._show_update(release, asked=True) + if release is not None: + self.update_found.emit(release) + + def _show_update(self, release, asked=False): + """What the line under the checkbox says, and whether the page button + is on it. `release` is None when this build is the newest one, and + `asked` is what tells "nothing new" from "nobody has looked yet".""" + self.update_page.setVisible(release is not None) + if release is None: + self.update_status.setText( + t("Dikte {version} is the newest release.", version=__version__) + if asked else t("This is Dikte {version}.", version=__version__)) + return + self._release_url = release.url + self.update_status.setText( + t("Dikte {version} is out; this is {current}.", + version=release.version, current=__version__)) + # ---- audio file ------------------------------------------------------ def _choose_file(self): diff --git a/dikte/update.py b/dikte/update.py new file mode 100644 index 0000000..4375996 --- /dev/null +++ b/dikte/update.py @@ -0,0 +1,158 @@ +"""Whether a newer Dikte has been published, and where to get it. + +GitHub is asked for the newest release, its number is held against the one this +build carries, and that is where it stops. Nothing is downloaded and nothing is +replaced. The four downloads are installed in four different ways, and three of +those belong to the platform rather than to Dikte: a Mac bundle is dragged into +Applications and cannot rewrite itself while it is running, the Windows setup +is an installer with an uninstall entry of its own, an AppImage is a single +file kept wherever its owner keeps it, and a checkout is updated with git. A +program that guessed at all four would be wrong on at least one of them, and +being wrong there means an installation somebody has to repair by hand. So the +answer ends in a browser, on the release page, where the same download that was +installed the first time is waiting. + +The clock is kept in a file of its own rather than in the settings. A check +runs while the settings window may be open, and a background write into +config.json is exactly what would overwrite a setting somebody is in the middle +of changing. + +Nothing here imports Qt or the rest of the application: `dikte update` at a +terminal and the timer behind the tray icon ask the same three questions of the +same module. +""" + +import collections +import itertools +import json +import time + +from . import __version__ +from . import hub +from . import paths + +REPO = "yusufipk/dikte" +# Where somebody is sent. GitHub redirects this to whatever the newest release +# is, so it stays right without anybody writing a number into it. +RELEASES_PAGE = f"https://github.com/{REPO}/releases/latest" + +# Once a day. A release happens every few weeks at best, and a question nobody +# is waiting on is not one to ask GitHub on every start. +INTERVAL = 24 * 3600 + +# When the last check was, what it found, and which version has already been +# announced. In the data directory rather than the config one: it is not a +# setting, nobody edits it, and losing it costs one extra request. +STATE_FILE = paths.DATA_DIR / "update.json" + +Release = collections.namedtuple("Release", "version url published") + + +def _numbers(version): + """(1, 0, 2) for "v1.0.2", "1.0.2" and "1.0.2-dev.abc1234" alike. + + Empty for anything that does not start with a number, which is what a tag + naming something other than a version comes back as. + """ + number = str(version or "").strip().lstrip("vV").split("-")[0].split("+")[0] + parts = [] + for piece in number.split("."): + digits = "".join(itertools.takewhile(str.isdigit, piece)) + if not digits: + break + parts.append(int(digits)) + return tuple((parts + [0, 0, 0])[:3]) if parts else () + + +def newer(there, here=""): + """Whether the release numbered `there` is one this build has not got. + + Only the numbers are compared, and what follows them is dropped. A build + off master carries the released number with its commit after it + (1.0.1-dev.abc1234), and that build is ahead of 1.0.1 rather than behind + it; read as a version suffix it would be behind, and every nightly would be + told to update to the release it was already past. + """ + theirs = _numbers(there) + return bool(theirs) and theirs > _numbers(here or __version__) + + +def state(): + """What the last check wrote down; empty when there has never been one.""" + try: + stored = json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return stored if isinstance(stored, dict) else {} + + +def _store(**changes): + stored = state() + stored.update(changes) + try: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(stored), encoding="utf-8") + except OSError: + pass # a check that cannot be written down still happened + return stored + + +def due(now=0): + """Whether a day has gone by since the last time anybody asked.""" + return (now or time.time()) - float(state().get("checked") or 0) >= INTERVAL + + +def latest(refresh=False): + """The newest published release, asked for outright. Raises HubError.""" + tag, url, published = hub.newest_release(REPO, refresh=refresh) + return Release(tag.lstrip("vV"), url or RELEASES_PAGE, published) + + +def remember(release): + """Write down that a check has just happened, and what it found.""" + _store(checked=time.time(), version=release.version, url=release.url, + published=release.published) + + +def pending(): + """The newer release the last check found, without asking anybody. + + What the tray icon is built from: the answer has to be there the moment it + appears, and a request on the way to the screen is a request nobody has + time for. + """ + stored = state() + version = stored.get("version") or "" + if not newer(version): + return None + return Release(version, stored.get("url") or RELEASES_PAGE, + stored.get("published") or "") + + +def check(force=False): + """A newer release, or None when there is nothing to say. + + The scheduled half: it asks only when a day has gone by, and answers from + what the last check found in between. `force` is the button in Settings and + the command line, which ask whatever the clock says. + + The clock here is the only throttle. Once it has decided to ask, it asks + for real rather than reading hub.py's few hours of cache, which is there to + keep a settings window from fetching the same model list twice in an + evening and would only ever answer this with something it already knew. + """ + if not force and not due(): + return pending() + release = latest(refresh=True) + remember(release) + return release if newer(release.version) else None + + +def announced(): + """The version somebody has already been shown a notification about.""" + return state().get("announced") or "" + + +def mark_announced(version): + """Said once. A daily check must not be a daily interruption.""" + _store(announced=version) diff --git a/tests/support.py b/tests/support.py index f6ec508..8a005f1 100644 --- a/tests/support.py +++ b/tests/support.py @@ -25,6 +25,7 @@ from unittest import mock from dikte import assistant from dikte import config as cfg from dikte import i18n +from dikte import update # What the application is, rather than what it does: PipeWire, wl-clipboard, # ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces @@ -86,8 +87,10 @@ class DikteTest(unittest.TestCase): MEETINGS_FILE=data_dir / "meetings.jsonl", ) # Resolved from cfg.DATA_DIR when assistant was imported, so it needs - # moving on its own. + # moving on its own. The same goes for where the update check writes + # down when it last ran. self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json") + self.patch_attr(update, "STATE_FILE", data_dir / "update.json") i18n.set_language("en") self.addCleanup(i18n.set_language, "en") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3c2de6d..585bad5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,8 @@ import contextlib import io import json import unittest +import webbrowser +from typing import ClassVar from unittest import mock from dikte import audio @@ -17,9 +19,11 @@ from dikte import cli from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import paste -from tests.support import DikteTest, fake_urlopen, only_these_tools +from dikte import update +from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error class Options: @@ -420,6 +424,68 @@ class Providers(DikteTest): self.assertIn("Groq", out) +class Updates(DikteTest): + """`dikte update` looks, says what it found, and installs nothing.""" + + RELEASE: ClassVar[dict] = { + "tag_name": "v9.9.9", + "html_url": "https://github.com/yusufipk/dikte/releases/tag/v9.9.9", + } + + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + # Nothing here may reach a browser, whatever the answer turns out to be. + self.opened = [] + self.patch_attr(webbrowser, "open", self.opened.append) + + def run_update(self, reply, **values): + with fake_urlopen(reply), captured() as (out, err): + code = cli.cmd_update(Options(open=False, **values)) + return code, out.getvalue(), err.getvalue() + + def test_a_newer_release_is_named_with_its_page(self): + code, out, _ = self.run_update(self.RELEASE) + self.assertEqual(code, 0) + self.assertIn("9.9.9", out) + self.assertIn(self.RELEASE["html_url"], out) + + def test_this_build_being_the_newest_is_not_a_failure(self): + code, out, _ = self.run_update({"tag_name": f"v{cli.__version__}"}) + self.assertEqual(code, 0) + self.assertIn("newest", out) + + def test_the_json_answer_says_both_numbers(self): + code, out, _ = self.run_update(self.RELEASE, json=True) + answer = json.loads(out) + self.assertTrue(answer["update"]) + self.assertEqual(answer["latest"], "9.9.9") + self.assertEqual(answer["current"], cli.__version__) + + def test_github_being_unreachable_is_a_failure_with_a_reason(self): + with fake_urlopen(url_error("no route to host")), captured() as (_, err): + code = cli.cmd_update(Options(open=False)) + self.assertEqual(code, 1) + self.assertIn("api.github.com", err.getvalue()) + + def test_the_browser_is_opened_only_when_asked_and_only_when_there_is_one(self): + self.run_update(self.RELEASE) + self.assertEqual(self.opened, []) + with fake_urlopen({"tag_name": f"v{cli.__version__}"}), captured(): + cli.cmd_update(Options(open=True)) + self.assertEqual(self.opened, []) + with fake_urlopen(self.RELEASE), captured(): + cli.cmd_update(Options(open=True)) + self.assertEqual(self.opened, [self.RELEASE["html_url"]]) + + def test_the_answer_is_written_down_for_the_application(self): + """A check at a terminal is a check; the tray must not go and ask the + same question an hour later.""" + self.run_update(self.RELEASE) + self.assertEqual(update.state()["version"], "9.9.9") + self.assertFalse(update.due()) + + class Doctor(DikteTest): """One pass over everything the settings window checks behind its buttons.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index 6cd402c..0b04be9 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -25,6 +25,7 @@ from dikte import ipc from dikte import overlay as overlay_module from dikte import paste from dikte import settings_ui +from dikte import update from tests.support import DikteTest, only_these_tools # One application for the whole run; Qt allows no second one. @@ -103,6 +104,7 @@ CHANGED = { "pause_shortcut": "Meta+P", "evdev_hotkey": True, "history_limit": 50, + "update_check": False, } @@ -252,6 +254,31 @@ class Settings(DikteTest): self.assertEqual(shown, [provider]) self.assertFalse(box.isHidden()) + def test_the_update_line_names_the_version_that_is_running(self): + window = self.window(cfg.Config()) + self.assertIn(settings_ui.__version__, window.update_status.text()) + # Nothing to open until a check has found something to open. + self.assertTrue(window.update_page.isHidden()) + + def test_a_newer_release_puts_the_page_button_on_screen(self): + window = self.window(cfg.Config()) + told = [] + window.update_found.connect(told.append) + release = update.Release("9.9.9", "https://example.invalid/9.9.9", "") + window._on_update_checked(release, "") + self.assertIn("9.9.9", window.update_status.text()) + self.assertFalse(window.update_page.isHidden()) + self.assertEqual(window._release_url, release.url) + # And the tray hears about it from here rather than waiting a day. + self.assertEqual(told, [release]) + + def test_a_check_that_failed_says_why_and_hands_the_button_back(self): + window = self.window(cfg.Config()) + window.update_now.setEnabled(False) + window._on_update_checked(None, "api.github.com answered HTTP 403.") + self.assertIn("403", window.update_status.text()) + self.assertTrue(window.update_now.isEnabled()) + def test_the_settings_the_window_does_not_show_are_left_alone(self): """A tab nobody wrote must not reset what the command line set.""" self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0, diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..06aca80 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,175 @@ +"""Whether a newer release is one worth telling somebody about. + +Two things carry the weight here. A version is compared by its numbers alone, +because a build off master carries the released number with its commit after +it and is ahead of that release rather than behind it. And the clock lives in a +file, so a day of asking nobody has to survive a restart. +""" + +import json +import time + +from dikte import hub +from dikte import update +from tests.support import DikteTest, fake_urlopen, url_error + +RELEASE = { + "tag_name": "v1.4.0", + "html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0", + "published_at": "2026-08-01T10:00:00Z", +} + + +class Numbers(DikteTest): + def test_a_tag_and_a_bare_number_read_the_same(self): + self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0)) + self.assertEqual(update._numbers("1.4.0"), (1, 4, 0)) + + def test_a_short_number_is_filled_out(self): + self.assertEqual(update._numbers("2"), (2, 0, 0)) + self.assertEqual(update._numbers("2.1"), (2, 1, 0)) + + def test_what_follows_the_number_is_dropped(self): + self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1)) + self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1)) + + def test_something_that_is_not_a_version_is_no_version(self): + self.assertEqual(update._numbers("latest"), ()) + self.assertEqual(update._numbers(""), ()) + self.assertEqual(update._numbers(None), ()) + + +class Newer(DikteTest): + def test_a_higher_number_is_newer(self): + self.assertTrue(update.newer("1.4.0", "1.3.9")) + self.assertTrue(update.newer("v2.0.0", "1.9.9")) + + def test_the_same_number_is_not(self): + self.assertFalse(update.newer("1.4.0", "1.4.0")) + self.assertFalse(update.newer("1.3.0", "1.4.0")) + + def test_a_build_off_master_is_ahead_of_the_release_it_names(self): + """1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it. + Read as a version suffix it would be older, and every nightly would be + told to go back to the release it had already passed.""" + self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234")) + self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234")) + + def test_a_tag_that_is_not_a_version_is_never_newer(self): + self.assertFalse(update.newer("nightly", "1.0.0")) + + +class Asking(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + self.patch_attr(update, "__version__", "1.0.0") + + def test_the_newest_release_comes_back_with_its_page(self): + with fake_urlopen(RELEASE) as calls: + release = update.latest() + self.assertEqual(release.version, "1.4.0") + self.assertEqual(release.url, RELEASE["html_url"]) + self.assertEqual( + calls[0].full_url, + "https://api.github.com/repos/yusufipk/dikte/releases/latest") + + def test_a_release_with_no_page_falls_back_to_the_redirect(self): + with fake_urlopen({"tag_name": "v1.4.0"}): + release = update.latest() + self.assertEqual(release.url, update.RELEASES_PAGE) + + def test_a_repository_with_no_release_is_an_error(self): + with fake_urlopen({"message": "Not Found"}): + with self.assertRaises(hub.HubError): + update.latest() + + def test_a_check_answers_with_the_newer_release(self): + with fake_urlopen(RELEASE): + release = update.check() + self.assertEqual(release.version, "1.4.0") + + def test_a_check_that_finds_nothing_new_answers_with_nothing(self): + self.patch_attr(update, "__version__", "1.4.0") + with fake_urlopen(RELEASE): + self.assertIsNone(update.check()) + + def test_a_second_check_the_same_day_asks_nobody(self): + with fake_urlopen(RELEASE) as calls: + update.check() + release = update.check() + self.assertEqual(len(calls), 1) + # And still says what the first one found, since it is still true. + self.assertEqual(release.version, "1.4.0") + + def test_a_day_later_it_asks_again(self): + with fake_urlopen(RELEASE) as calls: + update.check() + update._store(checked=time.time() - update.INTERVAL - 60) + update.check() + self.assertEqual(len(calls), 2) + + def test_the_button_asks_whatever_the_clock_says(self): + with fake_urlopen(RELEASE) as calls: + update.check() + update.check(force=True) + self.assertEqual(len(calls), 2) + + def test_a_check_that_cannot_reach_github_says_so(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(hub.HubError): + update.check() + + +class Remembering(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + self.patch_attr(update, "__version__", "1.0.0") + + def test_what_the_last_check_found_survives_a_restart(self): + with fake_urlopen(RELEASE): + update.check() + release = update.pending() + self.assertEqual(release.version, "1.4.0") + self.assertEqual(release.url, RELEASE["html_url"]) + + def test_nothing_was_ever_checked(self): + self.assertIsNone(update.pending()) + self.assertEqual(update.state(), {}) + self.assertTrue(update.due()) + + def test_a_release_that_is_no_longer_newer_is_not_pending(self): + """The state file outlives the build that wrote it: an update that was + found and then installed must not still be waiting afterwards.""" + update._store(version="1.4.0") + self.patch_attr(update, "__version__", "1.4.0") + self.assertIsNone(update.pending()) + + def test_a_version_is_announced_once(self): + self.assertEqual(update.announced(), "") + update.mark_announced("1.4.0") + self.assertEqual(update.announced(), "1.4.0") + + def test_a_state_file_that_is_rubbish_is_no_state_at_all(self): + update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + update.STATE_FILE.write_text("half a {", encoding="utf-8") + self.assertEqual(update.state(), {}) + self.assertIsNone(update.pending()) + + def test_a_state_file_that_cannot_be_written_is_not_a_failure(self): + self.patch_attr(update, "STATE_FILE", + self.path("nope") / "deeper" / "update.json") + self.path("nope").write_text("a file where a directory would go") + with fake_urlopen(RELEASE): + release = update.check() + self.assertEqual(release.version, "1.4.0") + + def test_the_clock_is_kept_out_of_the_settings(self): + """A background check writes while the settings window may be open, and + a write into config.json there would undo whatever it holds.""" + with fake_urlopen(RELEASE): + update.check() + stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8")) + self.assertEqual(stored["version"], "1.4.0") + self.assertGreater(stored["checked"], 0)