mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Say when a newer release is out, and stop there
A check on the releases page once a day: at start, on a timer while Dikte runs, from the General tab on demand, and from `dikte update` at a terminal. What it finds goes in the tray menu and in one notification per version, and opens the release page. Nothing is downloaded and nothing is installed. The four downloads are installed four different ways and three of those belong to the platform: a Mac bundle cannot rewrite itself while it is running, the Windows setup has an uninstall entry of its own, an AppImage is a file kept wherever its owner keeps it, and a checkout is updated with git. Being wrong about any one of them means an installation somebody has to repair by hand. Versions are compared by their numbers alone. A build off master carries the released number with its commit after it, and that build is ahead of the release it names rather than behind it; read as a version suffix, every nightly would be told to go back to a release it had already passed. The clock lives in its own file rather than in the settings, since a check runs while the settings window may be open and a background write into config.json is what would overwrite whatever it holds.
This commit is contained in:
@@ -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
|
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
|
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
|
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
|
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:
|
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` |
|
| 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` |
|
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
|
||||||
| Settings | Tray menu → *Settings*, or `dikte settings` |
|
| 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` |
|
| Reload after an update | Tray menu → *Restart*, or `dikte restart` |
|
||||||
| Quit | Tray menu → *Quit*, or `dikte quit` |
|
| 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
|
cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
|
||||||
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
||||||
hub.py what GitHub and Hugging Face have on offer today
|
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
|
worker.py transcribe → clean up → clipboard → paste
|
||||||
vad.py deciding whether a recording holds speech at all
|
vad.py deciding whether a recording holds speech at all
|
||||||
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
||||||
|
|||||||
+5
-1
@@ -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
|
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
|
verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
|
||||||
bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge`
|
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ı
|
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
|
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` |
|
| 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` |
|
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
|
||||||
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
|
| 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` |
|
| 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` |
|
| Çı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
|
cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
|
||||||
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
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
|
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
|
worker.py transkript → temizleme → pano → yapıştırma
|
||||||
vad.py kayıtta gerçekten konuşma var mı kararı
|
vad.py kayıtta gerçekten konuşma var mı kararı
|
||||||
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
||||||
|
|||||||
+99
-2
@@ -33,8 +33,9 @@ if sys.platform == "darwin":
|
|||||||
os.environ.get("PATH", "")) if part
|
os.environ.get("PATH", "")) if part
|
||||||
)
|
)
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
|
from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402
|
||||||
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
QUrl, pyqtSignal)
|
||||||
|
from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402
|
||||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||||
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # 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 config as cfg # noqa: E402
|
||||||
from . import ggml # noqa: E402
|
from . import ggml # noqa: E402
|
||||||
from . import hotkey # noqa: E402
|
from . import hotkey # noqa: E402
|
||||||
|
from . import hub # noqa: E402
|
||||||
from . import i18n # noqa: E402
|
from . import i18n # noqa: E402
|
||||||
from . import integrate # noqa: E402
|
from . import integrate # noqa: E402
|
||||||
from . import ipc # noqa: E402
|
from . import ipc # noqa: E402
|
||||||
from . import meeting # noqa: E402
|
from . import meeting # noqa: E402
|
||||||
from . import trayicon # noqa: E402
|
from . import trayicon # noqa: E402
|
||||||
|
from . import update # noqa: E402
|
||||||
from .i18n import t # noqa: E402
|
from .i18n import t # noqa: E402
|
||||||
from .meeting import MeetingPipeline # noqa: E402
|
from .meeting import MeetingPipeline # noqa: E402
|
||||||
from .overlay import Overlay # 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.
|
# short enough not to sit in the corner for the rest of the hour.
|
||||||
PEEK_MS = 12000
|
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:
|
class Dikte:
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
@@ -159,6 +193,17 @@ class Dikte:
|
|||||||
self.meeting_ticker.setInterval(500)
|
self.meeting_ticker.setInterval(500)
|
||||||
self.meeting_ticker.timeout.connect(self._meeting_tick)
|
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.tray = QSystemTrayIcon()
|
||||||
self._apply_settings()
|
self._apply_settings()
|
||||||
self.tray.show()
|
self.tray.show()
|
||||||
@@ -211,6 +256,11 @@ class Dikte:
|
|||||||
self.menu.addAction(self.meeting_cancel_action)
|
self.menu.addAction(self.meeting_cancel_action)
|
||||||
self.menu.addSeparator()
|
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 = QAction(t("Settings…"), self.menu)
|
||||||
self.settings_action.triggered.connect(self.open_settings)
|
self.settings_action.triggered.connect(self.open_settings)
|
||||||
self.menu.addAction(self.settings_action)
|
self.menu.addAction(self.settings_action)
|
||||||
@@ -227,6 +277,7 @@ class Dikte:
|
|||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
self.tray.setToolTip(t("Dikte: ready"))
|
self.tray.setToolTip(t("Dikte: ready"))
|
||||||
self.tray.activated.connect(self._tray_clicked)
|
self.tray.activated.connect(self._tray_clicked)
|
||||||
|
self._refresh_update()
|
||||||
self._set_icon("audio-input-microphone")
|
self._set_icon("audio-input-microphone")
|
||||||
|
|
||||||
def _tray_clicked(self, reason):
|
def _tray_clicked(self, reason):
|
||||||
@@ -901,12 +952,58 @@ class Dikte:
|
|||||||
if len(message) > len(first_line):
|
if len(message) > len(first_line):
|
||||||
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
|
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 ---------------------------------------------------------
|
# ---- settings ---------------------------------------------------------
|
||||||
|
|
||||||
def open_settings(self):
|
def open_settings(self):
|
||||||
if self.settings_window is None:
|
if self.settings_window is None:
|
||||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||||
self.settings_window.applied.connect(self._apply_settings)
|
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.finished.connect(self._settings_closed)
|
||||||
self.settings_window.show()
|
self.settings_window.show()
|
||||||
self.settings_window.raise_()
|
self.settings_window.raise_()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from PyQt6.QtCore import QCoreApplication, QTimer
|
from PyQt6.QtCore import QCoreApplication, QTimer
|
||||||
|
|
||||||
@@ -30,10 +31,12 @@ from . import cleanup
|
|||||||
from . import config as cfg
|
from . import config as cfg
|
||||||
from . import filetranscribe
|
from . import filetranscribe
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
|
from . import hub
|
||||||
from . import ipc
|
from . import ipc
|
||||||
from . import integrate
|
from . import integrate
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import update
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
|
||||||
NOT_RUNNING = 3
|
NOT_RUNNING = 3
|
||||||
@@ -797,6 +800,33 @@ def cmd_integrate(opts):
|
|||||||
f"{verb}:\n{listing}" if paths else "Nothing to change.")
|
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):
|
def cmd_status(opts):
|
||||||
reply = ipc.send("status")
|
reply = ipc.send("status")
|
||||||
if reply is None:
|
if reply is None:
|
||||||
@@ -1098,6 +1128,11 @@ def build_parser():
|
|||||||
integrated.set_defaults(func=cmd_integrate)
|
integrated.set_defaults(func=cmd_integrate)
|
||||||
|
|
||||||
# --- the application --------------------------------------------------
|
# --- 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)
|
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
|
||||||
for name, help_text in (("settings", "open the settings window"),
|
for name, help_text in (("settings", "open the settings window"),
|
||||||
("restart", "reload the running instance"),
|
("restart", "reload the running instance"),
|
||||||
|
|||||||
@@ -457,6 +457,9 @@ DEFAULTS = {
|
|||||||
"overlay_corner": "bottom-left",
|
"overlay_corner": "bottom-left",
|
||||||
"keep_audio": False,
|
"keep_audio": False,
|
||||||
"history_limit": 200,
|
"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_timestamps": False,
|
||||||
"file_cleanup": True,
|
"file_cleanup": True,
|
||||||
"file_cleanup_prompt": "", # empty -> language-specific default
|
"file_cleanup_prompt": "", # empty -> language-specific default
|
||||||
|
|||||||
@@ -136,6 +136,22 @@ def release(repo, tag="latest", refresh=False):
|
|||||||
return data.get("tag_name") or tag, assets
|
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):
|
def files(repo, revision="main", refresh=False):
|
||||||
"""[Item] for every file in a Hugging Face repository.
|
"""[Item] for every file in a Hugging Face repository.
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,25 @@ TR = {
|
|||||||
"Silence threshold": "Sessizlik eşiği",
|
"Silence threshold": "Sessizlik eşiği",
|
||||||
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
|
"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 --------------------------------------------------
|
# --- settings: api --------------------------------------------------
|
||||||
"Keys": "Anahtarlar",
|
"Keys": "Anahtarlar",
|
||||||
"Speech to text": "Sesi yazıya çevirme",
|
"Speech to text": "Sesi yazıya çevirme",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
from . import api
|
from . import api
|
||||||
from . import assistant
|
from . import assistant
|
||||||
from . import audio
|
from . import audio
|
||||||
@@ -21,9 +22,11 @@ from . import config as cfg
|
|||||||
from . import filetranscribe
|
from . import filetranscribe
|
||||||
from . import ggml
|
from . import ggml
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
|
from . import hub
|
||||||
from . import ipc
|
from . import ipc
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import update
|
||||||
from .filetranscribe import FileTranscriber
|
from .filetranscribe import FileTranscriber
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
@@ -512,11 +515,16 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
class SettingsWindow(QDialog):
|
class SettingsWindow(QDialog):
|
||||||
applied = pyqtSignal()
|
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)
|
_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
# Which key was tested, whether it worked, and what to write under it.
|
# Which key was tested, whether it worked, and what to write under it.
|
||||||
_test_done = pyqtSignal(str, bool, str)
|
_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):
|
def __init__(self, conf, meetings=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -533,6 +541,9 @@ class SettingsWindow(QDialog):
|
|||||||
self._key_fields = {}
|
self._key_fields = {}
|
||||||
self._testers = {}
|
self._testers = {}
|
||||||
self._shown_provider = ""
|
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.transcriber = FileTranscriber(conf, self)
|
||||||
self.setWindowTitle(t("Dikte Settings"))
|
self.setWindowTitle(t("Dikte Settings"))
|
||||||
|
|
||||||
@@ -567,6 +578,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._models_loaded.connect(self._on_models_loaded)
|
self._models_loaded.connect(self._on_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
self._test_done.connect(self._on_test_done)
|
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.progress.connect(self._on_file_progress)
|
||||||
self.transcriber.finished.connect(self._on_file_finished)
|
self.transcriber.finished.connect(self._on_file_finished)
|
||||||
self.transcriber.failed.connect(self._on_file_failed)
|
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))
|
t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR))
|
||||||
)
|
)
|
||||||
form.addRow("", self.keep_audio)
|
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
|
return page
|
||||||
|
|
||||||
def _api_tab(self):
|
def _api_tab(self):
|
||||||
@@ -1568,6 +1597,8 @@ class SettingsWindow(QDialog):
|
|||||||
self.silence_db.setValue(int(conf["silence_db"]))
|
self.silence_db.setValue(int(conf["silence_db"]))
|
||||||
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
||||||
self.keep_audio.setChecked(conf["keep_audio"])
|
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():
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
self._key_fields[name].setText(conf[who.key])
|
self._key_fields[name].setText(conf[who.key])
|
||||||
@@ -1661,6 +1692,7 @@ class SettingsWindow(QDialog):
|
|||||||
conf["silence_db"] = float(self.silence_db.value())
|
conf["silence_db"] = float(self.silence_db.value())
|
||||||
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
||||||
conf["keep_audio"] = self.keep_audio.isChecked()
|
conf["keep_audio"] = self.keep_audio.isChecked()
|
||||||
|
conf["update_check"] = self.update_check.isChecked()
|
||||||
|
|
||||||
provider = self.transcribe_provider.currentData() or "local"
|
provider = self.transcribe_provider.currentData() or "local"
|
||||||
if provider in self._models:
|
if provider in self._models:
|
||||||
@@ -1891,6 +1923,45 @@ class SettingsWindow(QDialog):
|
|||||||
button.setEnabled(True)
|
button.setEnabled(True)
|
||||||
answer.setText(("✓ " if ok else "✗ ") + message)
|
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 ------------------------------------------------------
|
# ---- audio file ------------------------------------------------------
|
||||||
|
|
||||||
def _choose_file(self):
|
def _choose_file(self):
|
||||||
|
|||||||
+158
@@ -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)
|
||||||
+4
-1
@@ -25,6 +25,7 @@ from unittest import mock
|
|||||||
from dikte import assistant
|
from dikte import assistant
|
||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import i18n
|
from dikte import i18n
|
||||||
|
from dikte import update
|
||||||
|
|
||||||
# What the application is, rather than what it does: PipeWire, wl-clipboard,
|
# 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
|
# 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",
|
MEETINGS_FILE=data_dir / "meetings.jsonl",
|
||||||
)
|
)
|
||||||
# Resolved from cfg.DATA_DIR when assistant was imported, so it needs
|
# 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(assistant, "SESSION_FILE", data_dir / "assistant.json")
|
||||||
|
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
|
||||||
|
|
||||||
i18n.set_language("en")
|
i18n.set_language("en")
|
||||||
self.addCleanup(i18n.set_language, "en")
|
self.addCleanup(i18n.set_language, "en")
|
||||||
|
|||||||
+67
-1
@@ -10,6 +10,8 @@ import contextlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
import webbrowser
|
||||||
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from dikte import audio
|
from dikte import audio
|
||||||
@@ -17,9 +19,11 @@ from dikte import cli
|
|||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import ggml
|
from dikte import ggml
|
||||||
from dikte import hotkey
|
from dikte import hotkey
|
||||||
|
from dikte import hub
|
||||||
from dikte import ipc
|
from dikte import ipc
|
||||||
from dikte import paste
|
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:
|
class Options:
|
||||||
@@ -420,6 +424,68 @@ class Providers(DikteTest):
|
|||||||
self.assertIn("Groq", out)
|
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):
|
class Doctor(DikteTest):
|
||||||
"""One pass over everything the settings window checks behind its buttons."""
|
"""One pass over everything the settings window checks behind its buttons."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from dikte import ipc
|
|||||||
from dikte import overlay as overlay_module
|
from dikte import overlay as overlay_module
|
||||||
from dikte import paste
|
from dikte import paste
|
||||||
from dikte import settings_ui
|
from dikte import settings_ui
|
||||||
|
from dikte import update
|
||||||
from tests.support import DikteTest, only_these_tools
|
from tests.support import DikteTest, only_these_tools
|
||||||
|
|
||||||
# One application for the whole run; Qt allows no second one.
|
# One application for the whole run; Qt allows no second one.
|
||||||
@@ -103,6 +104,7 @@ CHANGED = {
|
|||||||
"pause_shortcut": "Meta+P",
|
"pause_shortcut": "Meta+P",
|
||||||
"evdev_hotkey": True,
|
"evdev_hotkey": True,
|
||||||
"history_limit": 50,
|
"history_limit": 50,
|
||||||
|
"update_check": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -252,6 +254,31 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(shown, [provider])
|
self.assertEqual(shown, [provider])
|
||||||
self.assertFalse(box.isHidden())
|
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):
|
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."""
|
"""A tab nobody wrote must not reset what the command line set."""
|
||||||
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user