1__license__   = 'GPL v3'
2__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
3
4import re, ssl, json
5from threading import Thread, Event
6
7from qt.core import (QObject, pyqtSignal, Qt, QUrl, QDialog, QGridLayout,
8        QLabel, QCheckBox, QDialogButtonBox, QIcon)
9
10from calibre.constants import (__appname__, __version__, iswindows, ismacos,
11        isportable, is64bit, numeric_version)
12from calibre import prints, as_unicode
13from calibre.utils.config import prefs
14from calibre.utils.localization import localize_website_link
15from calibre.utils.https import get_https_resource_securely
16from calibre.gui2 import config, dynamic, open_url
17from calibre.gui2.dialogs.plugin_updater import get_plugin_updates_available
18from calibre.utils.serialize import msgpack_dumps, msgpack_loads
19from polyglot.binary import as_hex_unicode, from_hex_bytes
20
21URL = 'https://code.calibre-ebook.com/latest'
22# URL = 'http://localhost:8000/latest'
23NO_CALIBRE_UPDATE = (0, 0, 0)
24
25
26def get_download_url():
27    which = ('portable' if isportable else 'windows' if iswindows
28            else 'osx' if ismacos else 'linux')
29    if which == 'windows' and is64bit:
30        which += '64'
31    return localize_website_link('https://calibre-ebook.com/download_' + which)
32
33
34def get_newest_version():
35    try:
36        icon_theme_name = json.loads(I('icon-theme.json', data=True))['name']
37    except Exception:
38        icon_theme_name = ''
39    headers={
40        'CALIBRE-VERSION':__version__,
41        'CALIBRE-OS': ('win' if iswindows else 'osx' if ismacos else 'oth'),
42        'CALIBRE-INSTALL-UUID': prefs['installation_uuid'],
43        'CALIBRE-ICON-THEME': icon_theme_name,
44    }
45    try:
46        version = get_https_resource_securely(URL, headers=headers)
47    except ssl.SSLError as err:
48        if getattr(err, 'reason', None) != 'CERTIFICATE_VERIFY_FAILED':
49            raise
50        # certificate verification failed, since the version check contains no
51        # critical information, ignore and proceed
52        # We have to do this as if the calibre CA certificate ever
53        # needs to be revoked, then we won't be able to do version checks
54        version = get_https_resource_securely(URL, headers=headers, cacerts=None)
55    try:
56        version = version.decode('utf-8').strip()
57    except UnicodeDecodeError:
58        version = ''
59    ans = NO_CALIBRE_UPDATE
60    m = re.match(r'(\d+)\.(\d+).(\d+)$', version)
61    if m is not None:
62        ans = tuple(map(int, (m.group(1), m.group(2), m.group(3))))
63    return ans
64
65
66class Signal(QObject):
67
68    update_found = pyqtSignal(object, object)
69
70
71class CheckForUpdates(Thread):
72
73    INTERVAL = 24*60*60  # seconds
74    daemon = True
75
76    def __init__(self, parent):
77        Thread.__init__(self)
78        self.shutdown_event = Event()
79        self.signal = Signal(parent)
80
81    def run(self):
82        while not self.shutdown_event.is_set():
83            calibre_update_version = NO_CALIBRE_UPDATE
84            plugins_update_found = 0
85            try:
86                version = get_newest_version()
87                if version[:2] > numeric_version[:2]:
88                    calibre_update_version = version
89            except Exception as e:
90                prints('Failed to check for calibre update:', as_unicode(e))
91            try:
92                update_plugins = get_plugin_updates_available(raise_error=True)
93                if update_plugins is not None:
94                    plugins_update_found = len(update_plugins)
95            except Exception as e:
96                prints('Failed to check for plugin update:', as_unicode(e))
97            if calibre_update_version != NO_CALIBRE_UPDATE or plugins_update_found > 0:
98                self.signal.update_found.emit(calibre_update_version, plugins_update_found)
99            self.shutdown_event.wait(self.INTERVAL)
100
101    def shutdown(self):
102        self.shutdown_event.set()
103
104
105def version_key(calibre_version):
106    if isinstance(calibre_version, bytes):
107        calibre_version = calibre_version.decode('utf-8')
108    if calibre_version.count('.') > 1:
109        calibre_version = calibre_version.rpartition('.')[0]
110    return calibre_version
111
112
113def is_version_notified(calibre_version):
114    key = version_key(calibre_version)
115    done = dynamic.get('notified-version-updates') or set()
116    return key in done
117
118
119def save_version_notified(calibre_version):
120    done = dynamic.get('notified-version-updates') or set()
121    done.add(version_key(calibre_version))
122    dynamic.set('notified-version-updates', done)
123
124
125class UpdateNotification(QDialog):
126
127    def __init__(self, calibre_version, plugin_updates, parent=None):
128        QDialog.__init__(self, parent)
129        self.setAttribute(Qt.WidgetAttribute.WA_QuitOnClose, False)
130        self.resize(400, 250)
131        self.l = QGridLayout()
132        self.setLayout(self.l)
133        self.logo = QLabel()
134        self.logo.setMaximumWidth(110)
135        self.logo.setPixmap(QIcon(I('lt.png')).pixmap(100, 100))
136        ver = calibre_version
137        if ver.endswith('.0'):
138            ver = ver[:-2]
139        self.label = QLabel('<p>'+ _(
140            'New version <b>{ver}</b> of {app} is available for download. '
141            'See the <a href="{url}">new features</a>.').format(
142                url=localize_website_link('https://calibre-ebook.com/whats-new'),
143                app=__appname__, ver=ver))
144        self.label.setOpenExternalLinks(True)
145        self.label.setWordWrap(True)
146        self.setWindowTitle(_('Update available!'))
147        self.setWindowIcon(QIcon(I('lt.png')))
148        self.l.addWidget(self.logo, 0, 0)
149        self.l.addWidget(self.label, 0, 1)
150        self.cb = QCheckBox(
151            _('Show this notification for future updates'), self)
152        self.l.addWidget(self.cb, 1, 0, 1, -1)
153        self.cb.setChecked(config.get('new_version_notification'))
154        self.cb.stateChanged.connect(self.show_future)
155        self.bb = QDialogButtonBox(self)
156        b = self.bb.addButton(_('&Get update'), QDialogButtonBox.ButtonRole.AcceptRole)
157        b.setDefault(True)
158        b.setIcon(QIcon(I('arrow-down.png')))
159        if plugin_updates > 0:
160            b = self.bb.addButton(_('Update &plugins'), QDialogButtonBox.ButtonRole.ActionRole)
161            b.setIcon(QIcon(I('plugins/plugin_updater.png')))
162            b.clicked.connect(self.get_plugins, type=Qt.ConnectionType.QueuedConnection)
163        self.bb.addButton(QDialogButtonBox.StandardButton.Cancel)
164        self.l.addWidget(self.bb, 2, 0, 1, -1)
165        self.bb.accepted.connect(self.accept)
166        self.bb.rejected.connect(self.reject)
167        save_version_notified(calibre_version)
168
169    def get_plugins(self):
170        from calibre.gui2.dialogs.plugin_updater import (PluginUpdaterDialog,
171            FILTER_UPDATE_AVAILABLE)
172        d = PluginUpdaterDialog(self.parent(),
173                initial_filter=FILTER_UPDATE_AVAILABLE)
174        d.exec()
175        if d.do_restart:
176            QDialog.accept(self)
177            from calibre.gui2.ui import get_gui
178            gui = get_gui()
179            if gui is not None:
180                gui.quit(restart=True)
181
182    def show_future(self, *args):
183        config.set('new_version_notification', bool(self.cb.isChecked()))
184
185    def accept(self):
186        open_url(QUrl(get_download_url()))
187
188        QDialog.accept(self)
189
190
191class UpdateMixin:
192
193    def __init__(self, *args, **kw):
194        pass
195
196    def init_update_mixin(self, opts):
197        self.last_newest_calibre_version = NO_CALIBRE_UPDATE
198        if not opts.no_update_check:
199            self.update_checker = CheckForUpdates(self)
200            self.update_checker.signal.update_found.connect(self.update_found,
201                    type=Qt.ConnectionType.QueuedConnection)
202            self.update_checker.start()
203
204    def recalc_update_label(self, number_of_plugin_updates):
205        self.update_found(self.last_newest_calibre_version, number_of_plugin_updates)
206
207    def update_found(self, calibre_version, number_of_plugin_updates, force=False, no_show_popup=False):
208        self.last_newest_calibre_version = calibre_version
209        has_calibre_update = calibre_version != NO_CALIBRE_UPDATE and calibre_version[0] > 0
210        has_plugin_updates = number_of_plugin_updates > 0
211        self.plugin_update_found(number_of_plugin_updates)
212        version_url = as_hex_unicode(msgpack_dumps((calibre_version, number_of_plugin_updates)))
213        calibre_version = '.'.join(map(str, calibre_version))
214
215        if not has_calibre_update and not has_plugin_updates:
216            self.status_bar.update_label.setVisible(False)
217            return
218        if has_calibre_update:
219            plt = ''
220            if has_plugin_updates:
221                plt = ngettext(' and one plugin update', ' and {} plugin updates', number_of_plugin_updates).format(number_of_plugin_updates)
222            msg = ('<span style="color:green; font-weight: bold">%s: '
223                    '<a href="update:%s">%s%s</a></span>') % (
224                        _('Update found'), version_url, calibre_version, plt)
225        else:
226            plt = ngettext('updated plugin', 'updated plugins', number_of_plugin_updates)
227            msg = ('<a href="update:%s">%d %s</a>')%(version_url, number_of_plugin_updates, plt)
228        self.status_bar.update_label.setText(msg)
229        self.status_bar.update_label.setVisible(True)
230
231        if has_calibre_update:
232            if (force or (config.get('new_version_notification') and not is_version_notified(calibre_version))):
233                if not no_show_popup:
234                    self._update_notification__ = UpdateNotification(calibre_version,
235                            number_of_plugin_updates, parent=self)
236                    self._update_notification__.show()
237        elif has_plugin_updates:
238            if force:
239                from calibre.gui2.dialogs.plugin_updater import (PluginUpdaterDialog,
240                    FILTER_UPDATE_AVAILABLE)
241                d = PluginUpdaterDialog(self,
242                        initial_filter=FILTER_UPDATE_AVAILABLE)
243                d.exec()
244                if d.do_restart:
245                    self.quit(restart=True)
246
247    def plugin_update_found(self, number_of_updates):
248        # Change the plugin icon to indicate there are updates available
249        plugin = self.iactions.get('Plugin Updater', None)
250        if not plugin:
251            return
252        if number_of_updates:
253            plugin.qaction.setText(_('Plugin updates')+'*')
254            plugin.qaction.setIcon(QIcon(I('plugins/plugin_updater_updates.png')))
255            plugin.qaction.setToolTip(
256                ngettext('A plugin update is available',
257                         'There are {} plugin updates available', number_of_updates).format(number_of_updates))
258        else:
259            plugin.qaction.setText(_('Plugin updates'))
260            plugin.qaction.setIcon(QIcon(I('plugins/plugin_updater.png')))
261            plugin.qaction.setToolTip(_('Install and configure user plugins'))
262
263    def update_link_clicked(self, url):
264        url = str(url)
265        if url.startswith('update:'):
266            calibre_version, number_of_plugin_updates = msgpack_loads(from_hex_bytes(url[len('update:'):]))
267            self.update_found(calibre_version, number_of_plugin_updates, force=True)
268
269
270if __name__ == '__main__':
271    from calibre.gui2 import Application
272    app = Application([])
273    UpdateNotification('x.y.z', False).exec()
274    del app
275