From d3ad15dc516515329fb4f85f43867168d1dd30a7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 1 Feb 2019 12:08:35 +0530 Subject: [PATCH] Automatically check for new releases and notify when an update is available, via the system notification facilities. Can be controlled by :opt:`update_check_interval` Fixes #1342 --- docs/changelog.rst | 4 ++ kitty/boss.py | 25 +++++++++- kitty/cocoa_window.m | 52 +++++--------------- kitty/config_data.py | 8 +++- kitty/notify.py | 102 ++++++++++++--------------------------- kitty/update_check.py | 109 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 115 deletions(-) create mode 100644 kitty/update_check.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 34f42796c..a3537dc3e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,10 @@ Changelog - A new option :opt:`strip_trailing_spaces` to optionally remove trailing spaces from lines when copying to clipboard. +- Automatically check for new releases and notify when an update is available, + via the system notification facilities. Can be controlled by + :opt:`update_check_interval` (:iss:`1342`) + - macOS: Fix :kbd:`cmd+period` key not working (:iss:`1318`) - macOS: Add an option :opt:`macos_show_window_title_in_menubar` to not diff --git a/kitty/boss.py b/kitty/boss.py index b68d44683..3137034c2 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -16,7 +16,8 @@ from .conf.utils import to_cmdline from .config import initial_window_size_func, prepare_config_file_for_editing from .config_data import MINIMUM_FONT_SIZE from .constants import ( - appname, config_dir, kitty_exe, set_boss, supports_primary_selection + appname, config_dir, is_macos, kitty_exe, set_boss, + supports_primary_selection ) from .fast_data_types import ( ChildMonitor, background_opacity_of, change_background_opacity, @@ -136,6 +137,9 @@ class Boss: self.toggle_fullscreen() else: change_os_window_state(args.start_as) + if is_macos: + from .fast_data_types import cocoa_set_notification_activated_callback + cocoa_set_notification_activated_callback(self.notification_activated) def add_os_window(self, startup_session, os_window_id=None, wclass=None, wname=None, opts_for_size=None, startup_id=None): if os_window_id is None: @@ -355,6 +359,10 @@ class Boss: if not getattr(self, 'io_thread_started', False): self.child_monitor.start() self.io_thread_started = True + if self.opts.update_check_interval > 0 and not hasattr(self, 'update_check_started'): + from .update_check import run_update_check + run_update_check(self.opts.update_check_interval * 60 * 60) + self.update_check_started = True def activate_tab_at(self, os_window_id, x): tm = self.os_window_map.get(os_window_id) @@ -973,3 +981,18 @@ class Boss: os.remove(path) except FileNotFoundError: pass + + def set_update_check_process(self, process): + self.update_check_process = process + + def on_monitored_pid_death(self, pid, exit_status): + update_check_process = getattr(self, 'update_check_process', None) + if update_check_process is not None and pid == update_check_process.pid: + self.update_check_process = None + from .update_check import process_current_release + process_current_release(update_check_process.stdout.read().decode('utf-8')) + + def notification_activated(self, identifier): + if identifier == 'new-version': + from .update_check import notification_activated + notification_activated() diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index 5c704349f..5f653d43e 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -116,6 +116,14 @@ get_dock_menu(id self UNUSED, SEL _cmd UNUSED, NSApplication *sender UNUSED) { } static PyObject *notification_activated_callback = NULL; +static PyObject* +set_notification_activated_callback(PyObject *self UNUSED, PyObject *callback) { + if (notification_activated_callback) Py_DECREF(notification_activated_callback); + notification_activated_callback = callback; + Py_INCREF(callback); + Py_RETURN_NONE; + +} @interface NotificationDelegate : NSObject @end @@ -177,46 +185,6 @@ cocoa_send_notification(PyObject *self UNUSED, PyObject *args) { Py_RETURN_NONE; } -static void -call_timer_callback(PyObject *timer_callback) { - PyObject *ret = PyObject_CallObject(timer_callback, NULL); - if (ret == NULL) PyErr_Print(); - else Py_DECREF(ret); -} - -static PyObject* -cocoa_run_notification_loop(PyObject *self UNUSED, PyObject *args) { - PyObject *timer_callback; - double timeout; - if (!PyArg_ParseTuple(args, "OOd", ¬ification_activated_callback, &timer_callback, &timeout)) return NULL; - NSAutoreleasePool *pool = [NSAutoreleasePool new]; - NSApplication * application = [NSApplication sharedApplication]; - // prevent icon in dock - [application setActivationPolicy:NSApplicationActivationPolicyAccessory]; - signal(SIGTERM, SIG_IGN); - signal(SIGINT, SIG_IGN); - dispatch_source_t sigint = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, SIGINT, 0, dispatch_get_main_queue()); - dispatch_source_set_event_handler(sigint, ^{ - [application terminate:nil]; - }); - dispatch_resume(sigint); - dispatch_source_t sigterm = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, SIGINT, 0, dispatch_get_main_queue()); - dispatch_source_set_event_handler(sigterm, ^{ - [application terminate:nil]; - }); - dispatch_resume(sigterm); - // timer will fire after timeout, so fire it once at the start - call_timer_callback(timer_callback); - [NSTimer scheduledTimerWithTimeInterval:timeout - repeats:YES - block:^(NSTimer *timer UNUSED) { - call_timer_callback(timer_callback); - }]; - [application run]; - [pool drain]; - return 0; -} - void cocoa_create_global_menu(void) { NSString* app_name = find_app_name(); @@ -420,13 +388,15 @@ static void cleanup() { if (dockMenu) [dockMenu release]; dockMenu = nil; + if (notification_activated_callback) Py_DECREF(notification_activated_callback); + notification_activated_callback = NULL; } static PyMethodDef module_methods[] = { {"cocoa_get_lang", (PyCFunction)cocoa_get_lang, METH_NOARGS, ""}, {"cocoa_set_new_window_trigger", (PyCFunction)cocoa_set_new_window_trigger, METH_VARARGS, ""}, {"cocoa_send_notification", (PyCFunction)cocoa_send_notification, METH_VARARGS, ""}, - {"cocoa_run_notification_loop", (PyCFunction)cocoa_run_notification_loop, METH_VARARGS, ""}, + {"cocoa_set_notification_activated_callback", (PyCFunction)set_notification_activated_callback, METH_O, ""}, {NULL, NULL, 0, NULL} /* Sentinel */ }; diff --git a/kitty/config_data.py b/kitty/config_data.py index 30ef55ede..416473c9f 100644 --- a/kitty/config_data.py +++ b/kitty/config_data.py @@ -362,8 +362,6 @@ Use negative numbers to change scroll direction.''')) # }}} -# }}} - g('mouse') # {{{ o('url_color', '#0087BD', option_type=to_color, long_text=_(''' @@ -735,6 +733,12 @@ environment variables are expanded recursively, so if you use:: The value of MYVAR2 will be :code:`a//b`. ''')) +o('update_check_interval', 24, option_type=float, long_text=_(''' +Periodically check if an update to kitty is available. If an update is found +a system notification is displayed informing you of the available update. +The default is to check every 24 hrs, set to zero to disable. +''')) + def startup_session(x): if x.lower() == 'none': diff --git a/kitty/notify.py b/kitty/notify.py index 89dacccab..9122b5a0d 100644 --- a/kitty/notify.py +++ b/kitty/notify.py @@ -2,89 +2,49 @@ # vim:fileencoding=utf-8 # License: GPLv3 Copyright: 2019, Kovid Goyal - -import os import subprocess -import time -from urllib.request import urlopen - -from .constants import cache_dir, is_macos, logo_png_file, version -from .utils import open_url - -CHANGELOG_URL = 'https://sw.kovidgoyal.net/kitty/changelog.html' -RELEASED_VERSION_URL = 'https://sw.kovidgoyal.net/kitty/current-version.txt' -CHECK_INTERVAL = 24 * 60 * 60 - - -def version_notification_log(): - return os.path.join(cache_dir(), 'new-version-notifications.txt') - - -def notify_new_version(version): - notify('kitty update available!', 'kitty version {} released'.format('.'.join(map(str, version)))) - - -def get_released_version(): - try: - raw = urlopen(RELEASED_VERSION_URL).read().decode('utf-8').strip() - except Exception: - raw = '0.0.0' - return tuple(map(int, raw.split('.'))) - - -notified_versions = set() - - -def save_notification(version): - notified_versions.add(version) - version = '.'.join(map(str, version)) - with open(version_notification_log(), 'a') as f: - print(version, file=f) - - -def already_notified(version): - if not hasattr(already_notified, 'read_cache'): - already_notified.read_cache = True - with open(version_notification_log()) as f: - for line in f: - notified_versions.add(tuple(map(int, line.strip().split('.')))) - return tuple(version) in notified_versions +from .constants import is_macos, logo_png_file if is_macos: - from .fast_data_types import cocoa_send_notification, cocoa_run_notification_loop + from .fast_data_types import cocoa_send_notification - def notify(title, body, timeout=5000, application='kitty', icon=True, identifier=None): + def notify( + title, + body, + timeout=5000, + application='kitty', + icon=True, + identifier=None + ): if icon is True: icon = None cocoa_send_notification(identifier, title, body, icon) - def notification_activated(notification_identifier): - open_url(CHANGELOG_URL) - - def do_check(): - new_version = get_released_version() - if new_version > version and not already_notified(new_version): - save_notification(new_version) - notify_new_version(new_version) - - def update_check(): - cocoa_run_notification_loop(notification_activated, do_check, CHECK_INTERVAL) - else: - def notify(title, body, timeout=5000, application='kitty', icon=True, identifier=None): - cmd = ['notify-send', '-t', str(timeout), '-a', application] + + # libnotify depends on GTK, so we are not using it, instead + # use the command line notify-send wrapper it provides + # May want to just implement this in glfw using DBUS + + def notify( + title, + body, + timeout=-1, + application='kitty', + icon=True, + identifier=None + ): + cmd = ['notify-send', '-a', application] + if timeout > -1: + cmd.append('-t'), cmd.append(str(timeout)) if icon is True: icon = logo_png_file if icon: cmd.extend(['-i', icon]) subprocess.Popen( - cmd + [title, body], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL) - - def update_check(): - while True: - new_version = get_released_version() - if new_version > version and not already_notified(new_version): - save_notification(new_version) - notify_new_version(new_version) - time.sleep(CHECK_INTERVAL) + cmd + [title, body], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + ) diff --git a/kitty/update_check.py b/kitty/update_check.py new file mode 100644 index 000000000..643adcad7 --- /dev/null +++ b/kitty/update_check.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2019, Kovid Goyal + +import os +import subprocess +import time +from collections import namedtuple +from urllib.request import urlopen + +from .config import atomic_save +from .constants import cache_dir, get_boss, version +from .fast_data_types import add_timer, monitor_pid +from .notify import notify +from .utils import open_url + +CHANGELOG_URL = 'https://sw.kovidgoyal.net/kitty/changelog.html' +RELEASED_VERSION_URL = 'https://sw.kovidgoyal.net/kitty/current-version.txt' +CHECK_INTERVAL = 24 * 60 * 60 +Notification = namedtuple('Notification', 'version time_of_last_notification count') + + +def notification_activated(): + open_url(CHANGELOG_URL) + + +def version_notification_log(): + override = getattr(version_notification_log, 'override', None) + if override: + return override + return os.path.join(cache_dir(), 'new-version-notifications-1.txt') + + +def notify_new_version(release_version): + notify( + 'kitty update available!', + 'kitty version {} released'.format('.'.join(map(str, release_version))), + identifier='new-version', + ) + + +def get_released_version(): + try: + raw = urlopen(RELEASED_VERSION_URL).read().decode('utf-8').strip() + except Exception: + raw = '0.0.0' + return raw + + +def parse_line(line): + parts = line.split(',') + version, timestamp, count = parts + version = tuple(map(int, version.split('.'))) + return Notification(version, float(timestamp), int(count)) + + +def read_cache(): + notified_versions = {} + try: + with open(version_notification_log()) as f: + for line in f: + try: + n = parse_line(line) + except Exception: + continue + notified_versions[n.version] = n + except FileNotFoundError: + pass + return notified_versions + + +def already_notified(version): + notified_versions = read_cache() + return version in notified_versions + + +def save_notification(version): + notified_versions = read_cache() + if version in notified_versions: + v = notified_versions[version] + notified_versions[version] = v._replace(time_of_last_notification=time.time(), count=v.count + 1) + else: + notified_versions[version] = Notification(version, time.time(), 1) + lines = [] + for version in sorted(notified_versions): + n = notified_versions[version] + lines.append('{},{},{}'.format( + '.'.join(map(str, n.version)), n.time_of_last_notification, n.count)) + atomic_save('\n'.join(lines).encode('utf-8'), version_notification_log()) + + +def process_current_release(raw): + release_version = tuple(map(int, raw.split('.'))) + if release_version > version and not already_notified(release_version): + save_notification(release_version) + notify_new_version(release_version) + + +def update_check(timer_id=None): + p = subprocess.Popen([ + 'kitty', '+runpy', 'from kitty.update_check import *; import time; time.sleep(2); print(get_released_version())' + ], stdout=subprocess.PIPE) + monitor_pid(p.pid) + get_boss().set_update_check_process(p) + + +def run_update_check(interval=24 * 60 * 60): + update_check() + add_timer('update_check', update_check, interval)