1from scudcloud.cookiejar import PersistentCookieJar
2from scudcloud.leftpane import LeftPane
3from scudcloud.notifier import Notifier
4from scudcloud.resources import Resources
5from scudcloud.systray import Systray
6from scudcloud.wrapper import Wrapper
7from scudcloud.speller import Speller
8
9import sys, os, time
10
11from threading import Thread
12from PyQt5 import QtCore, QtGui, QtWebKit, QtWidgets, QtWebKitWidgets
13from PyQt5.Qt import QApplication, QKeySequence, QTimer
14from PyQt5.QtCore import QUrl, QSettings
15from PyQt5.QtWebKit import QWebSettings
16from PyQt5.QtWebKitWidgets import QWebPage
17from PyQt5.QtNetwork import QNetworkDiskCache, QNetworkAccessManager
18
19# Auto-detection of dbus and dbus.mainloop.qt
20try:
21    import dbus
22    from dbus.mainloop.pyqt5 import DBusQtMainLoop
23except ImportError:
24    DBusQtMainLoop = None
25
26# Auto-detection of Unity and Dbusmenu in gi repository
27try:
28    import gi
29    gi.require_version('Unity', '7.0')
30    from gi.repository import Unity, Dbusmenu
31except (ImportError, ValueError):
32    Unity = None
33    Dbusmenu = None
34    from scudcloud.launcher import DummyLauncher
35
36class ScudCloud(QtWidgets.QMainWindow):
37
38    forceClose = False
39    messages = 0
40    speller = Speller()
41    title = 'ScudCloud'
42
43    def __init__(self, debug = False, minimized = None, urgent_hint = None, settings_path = '', cache_path = ''):
44        super(ScudCloud, self).__init__(None)
45        self.debug = debug
46        self.minimized = minimized
47        self.urgent_hint = urgent_hint
48        self.setWindowTitle(self.title)
49        self.settings_path = settings_path
50        self.cache_path = cache_path
51        self.notifier = Notifier(Resources.APP_NAME, Resources.get_path('scudcloud.png'))
52        self.settings = QSettings(self.settings_path + '/scudcloud_qt5.cfg', QSettings.IniFormat)
53        self.notifier.enabled = self.settings.value('Notifications', defaultValue=True, type=bool)
54        self.identifier = self.settings.value("Domain")
55        if Unity is not None:
56            self.launcher = Unity.LauncherEntry.get_for_desktop_id("scudcloud.desktop")
57        else:
58            self.launcher = DummyLauncher(self)
59        self.webSettings()
60        self.snippetsSettings()
61        self.leftPane = LeftPane(self)
62        self.stackedWidget = QtWidgets.QStackedWidget()
63        centralWidget = QtWidgets.QWidget(self)
64        layout = QtWidgets.QHBoxLayout()
65        layout.setContentsMargins(0, 0, 0, 0)
66        layout.setSpacing(0)
67        layout.addWidget(self.leftPane)
68        layout.addWidget(self.stackedWidget)
69        centralWidget.setLayout(layout)
70        self.setCentralWidget(centralWidget)
71        self.startURL = Resources.SIGNIN_URL
72        if self.identifier is not None:
73            if isinstance(self.identifier, str):
74                self.domains = self.identifier.split(",")
75            else:
76                self.domains = self.identifier
77            self.startURL = self.normalize(self.domains[0])
78        else:
79            self.domains = []
80        self.addWrapper(self.startURL)
81        self.addMenu()
82        self.tray = Systray(self)
83        self.systray(self.minimized)
84        self.installEventFilter(self)
85        self.statusBar().showMessage('Loading Slack...')
86        self.tickler = QTimer(self)
87        self.tickler.setInterval(1800000)
88        # Watch for ScreenLock events
89        if DBusQtMainLoop is not None:
90            DBusQtMainLoop(set_as_default=True)
91            sessionBus = dbus.SessionBus()
92            # Ubuntu 12.04 and other distros
93            sessionBus.add_match_string("type='signal',interface='org.gnome.ScreenSaver'")
94            # Ubuntu 14.04
95            sessionBus.add_match_string("type='signal',interface='com.ubuntu.Upstart0_6'")
96            # Ubuntu 16.04 and KDE
97            sessionBus.add_match_string("type='signal',interface='org.freedesktop.ScreenSaver'")
98            # Cinnamon
99            sessionBus.add_match_string("type='signal',interface='org.cinnamon.ScreenSaver'")
100            sessionBus.add_message_filter(self.screenListener)
101            self.tickler.timeout.connect(self.sendTickle)
102        # If dbus is not present, tickler timer will act like a blocker to not send tickle too often
103        else:
104            self.tickler.setSingleShot(True)
105        self.tickler.start()
106
107    def screenListener(self, bus, message):
108        event = message.get_member()
109        # "ActiveChanged" for Ubuntu 12.04 and other distros. "EventEmitted" for Ubuntu 14.04 and above
110        if event == "ActiveChanged" or event == "EventEmitted":
111            arg = message.get_args_list()[0]
112            # True for Ubuntu 12.04 and other distros. "desktop-lock" for Ubuntu 14.04 and above
113            if (arg == True or arg == "desktop-lock") and self.tickler.isActive():
114                self.tickler.stop()
115            elif (arg == False or arg == "desktop-unlock") and not self.tickler.isActive():
116                self.sendTickle()
117                self.tickler.start()
118
119    def sendTickle(self):
120        for i in range(0, self.stackedWidget.count()):
121            self.stackedWidget.widget(i).sendTickle()
122
123    def addWrapper(self, url):
124        webView = Wrapper(self)
125        webView.load(QtCore.QUrl(url))
126        webView.show()
127        webView.setZoomFactor(self.zoom)
128        self.stackedWidget.addWidget(webView)
129        self.stackedWidget.setCurrentWidget(webView)
130        self.clearMemory()
131
132    def webSettings(self):
133        self.cookiesjar = PersistentCookieJar(self)
134        self.zoom = self.readZoom()
135        # We don't want Flash (it causes a lot of trouble in some distros)
136        QWebSettings.globalSettings().setAttribute(QWebSettings.PluginsEnabled, False)
137        # We don't need Java
138        QWebSettings.globalSettings().setAttribute(QWebSettings.JavaEnabled, False)
139        # Enabling Local Storage (now required by Slack)
140        QWebSettings.globalSettings().setAttribute(QWebSettings.LocalStorageEnabled, True)
141        # We need browsing history (required to not limit LocalStorage)
142        QWebSettings.globalSettings().setAttribute(QWebSettings.PrivateBrowsingEnabled, False)
143        # Enabling Cache
144        self.diskCache = QNetworkDiskCache(self)
145        self.diskCache.setCacheDirectory(self.cache_path)
146        # Required for copy and paste clipboard integration
147        QWebSettings.globalSettings().setAttribute(QWebSettings.JavascriptCanAccessClipboard, True)
148        # Enabling Inspeclet only when --debug=True (requires more CPU usage)
149        QWebSettings.globalSettings().setAttribute(QWebSettings.DeveloperExtrasEnabled, self.debug)
150        # Sharing the same networkAccessManager
151        self.networkAccessManager = QNetworkAccessManager(self)
152        self.networkAccessManager.setCookieJar(self.cookiesjar)
153        self.networkAccessManager.setCache(self.diskCache)
154
155    def snippetsSettings(self):
156        self.disable_snippets = self.settings.value("Snippets")
157        if self.disable_snippets is not None:
158            self.disable_snippets = self.disable_snippets == "False"
159        else:
160            self.disable_snippets = False
161        if self.disable_snippets:
162            disable_snippets_css = ''
163            with open(Resources.get_path('disable_snippets.css'), 'r') as f:
164                disable_snippets_css = f.read()
165            with open(os.path.join(self.cache_path, 'resources.css'), 'a') as f:
166                f.write(disable_snippets_css)
167
168    def toggleFullScreen(self):
169        if self.isFullScreen():
170            self.showMaximized()
171        else:
172            self.showFullScreen()
173
174    def toggleMenuBar(self):
175        menu = self.menuBar()
176        state = menu.isHidden()
177        menu.setVisible(state)
178        if state:
179            self.settings.setValue("Menu", "True")
180        else:
181            self.settings.setValue("Menu", "False")
182
183    def restore(self):
184        geometry = self.settings.value("geometry")
185        if geometry is not None:
186            self.restoreGeometry(geometry)
187        windowState = self.settings.value("windowState")
188        if windowState is not None:
189            self.restoreState(windowState)
190        else:
191            self.setWindowState(QtCore.Qt.WindowMaximized)
192
193    def systray(self, show=None):
194        if show is None:
195            show = self.settings.value("Systray") == "True"
196        if show:
197            self.tray.show()
198            self.menus["file"]["close"].setEnabled(True)
199            self.settings.setValue("Systray", "True")
200        else:
201            self.tray.setVisible(False)
202            self.menus["file"]["close"].setEnabled(False)
203            self.settings.setValue("Systray", "False")
204
205    def readZoom(self):
206        default = 1
207        if self.settings.value("Zoom") is not None:
208            default = float(self.settings.value("Zoom"))
209        return default
210
211    def setZoom(self, factor=1):
212        if factor > 0:
213            for i in range(0, self.stackedWidget.count()):
214                widget = self.stackedWidget.widget(i)
215                widget.setZoomFactor(factor)
216            self.settings.setValue("Zoom", factor)
217
218    def zoomIn(self):
219        self.setZoom(self.current().zoomFactor() + 0.1)
220
221    def zoomOut(self):
222        self.setZoom(self.current().zoomFactor() - 0.1)
223
224    def zoomReset(self):
225        self.setZoom()
226
227    def addTeam(self):
228        self.switchTo(Resources.SIGNIN_URL)
229
230    def addMenu(self):
231        # We'll register the webpage shorcuts with the window too (Fixes #338)
232        undo = self.current().pageAction(QWebPage.Undo)
233        redo = self.current().pageAction(QWebPage.Redo)
234        cut = self.current().pageAction(QWebPage.Cut)
235        copy = self.current().pageAction(QWebPage.Copy)
236        paste = self.current().pageAction(QWebPage.Paste)
237        back = self.current().pageAction(QWebPage.Back)
238        forward = self.current().pageAction(QWebPage.Forward)
239        reload = self.current().pageAction(QWebPage.Reload)
240        self.menus = {
241            "file": {
242                "preferences": self.createAction("Preferences", lambda : self.current().preferences()),
243                "systray":     self.createAction("Close to Tray", self.systray, None, True),
244                "addTeam":     self.createAction("Sign in to Another Team", lambda : self.addTeam()),
245                "signout":     self.createAction("Signout", lambda : self.current().logout()),
246                "close":       self.createAction("Close", self.close, QKeySequence.Close),
247                "exit":        self.createAction("Quit", self.exit, QKeySequence.Quit)
248            },
249            "edit": {
250                "undo":        self.createAction(undo.text(), lambda : self.current().page().triggerAction(QWebPage.Undo), undo.shortcut()),
251                "redo":        self.createAction(redo.text(), lambda : self.current().page().triggerAction(QWebPage.Redo), redo.shortcut()),
252                "cut":         self.createAction(cut.text(), lambda : self.current().page().triggerAction(QWebPage.Cut), cut.shortcut()),
253                "copy":        self.createAction(copy.text(), lambda : self.current().page().triggerAction(QWebPage.Copy), copy.shortcut()),
254                "paste":       self.createAction(paste.text(), lambda : self.current().page().triggerAction(QWebPage.Paste), paste.shortcut()),
255                "back":        self.createAction(back.text(), lambda : self.current().page().triggerAction(QWebPage.Back), back.shortcut()),
256                "forward":     self.createAction(forward.text(), lambda : self.current().page().triggerAction(QWebPage.Forward), forward.shortcut()),
257                "reload":      self.createAction(reload.text(), lambda : self.current().page().triggerAction(QWebPage.Reload), reload.shortcut()),
258            },
259            "view": {
260                "zoomin":      self.createAction("Zoom In", self.zoomIn, QKeySequence.ZoomIn),
261                "zoomout":     self.createAction("Zoom Out", self.zoomOut, QKeySequence.ZoomOut),
262                "reset":       self.createAction("Reset", self.zoomReset, QtCore.Qt.CTRL + QtCore.Qt.Key_0),
263                "fullscreen":  self.createAction("Toggle Full Screen", self.toggleFullScreen, QtCore.Qt.Key_F11),
264                "hidemenu":    self.createAction("Toggle Menubar", self.toggleMenuBar, QtCore.Qt.Key_F12)
265            },
266            "help": {
267                "help":       self.createAction("Help and Feedback", lambda : self.current().help(), QKeySequence.HelpContents),
268                "center":     self.createAction("Slack Help Center", lambda : self.current().helpCenter()),
269                "about":      self.createAction("About", lambda : self.current().about())
270             }
271        }
272        menu = self.menuBar()
273        fileMenu = menu.addMenu("&File")
274        fileMenu.addAction(self.menus["file"]["preferences"])
275        fileMenu.addAction(self.menus["file"]["systray"])
276        fileMenu.addSeparator()
277        fileMenu.addAction(self.menus["file"]["addTeam"])
278        fileMenu.addAction(self.menus["file"]["signout"])
279        fileMenu.addSeparator()
280        fileMenu.addAction(self.menus["file"]["close"])
281        fileMenu.addAction(self.menus["file"]["exit"])
282        editMenu = menu.addMenu("&Edit")
283        editMenu.addAction(self.menus["edit"]["undo"])
284        editMenu.addAction(self.menus["edit"]["redo"])
285        editMenu.addSeparator()
286        editMenu.addAction(self.menus["edit"]["cut"])
287        editMenu.addAction(self.menus["edit"]["copy"])
288        editMenu.addAction(self.menus["edit"]["paste"])
289        editMenu.addSeparator()
290        editMenu.addAction(self.menus["edit"]["back"])
291        editMenu.addAction(self.menus["edit"]["forward"])
292        editMenu.addAction(self.menus["edit"]["reload"])
293        viewMenu = menu.addMenu("&View")
294        viewMenu.addAction(self.menus["view"]["zoomin"])
295        viewMenu.addAction(self.menus["view"]["zoomout"])
296        viewMenu.addAction(self.menus["view"]["reset"])
297        viewMenu.addSeparator()
298        viewMenu.addAction(self.menus["view"]["fullscreen"])
299        viewMenu.addAction(self.menus["view"]["hidemenu"])
300        helpMenu = menu.addMenu("&Help")
301        helpMenu.addAction(self.menus["help"]["help"])
302        helpMenu.addAction(self.menus["help"]["center"])
303        helpMenu.addSeparator()
304        helpMenu.addAction(self.menus["help"]["about"])
305        self.enableMenus(False)
306        showSystray = self.settings.value("Systray") == "True"
307        self.menus["file"]["systray"].setChecked(showSystray)
308        self.menus["file"]["close"].setEnabled(showSystray)
309        # Restore menu visibility
310        visible = self.settings.value("Menu")
311        if visible is not None and visible == "False":
312            menu.setVisible(False)
313
314    def enableMenus(self, enabled):
315        self.menus["file"]["preferences"].setEnabled(enabled == True)
316        self.menus["file"]["addTeam"].setEnabled(enabled == True)
317        self.menus["file"]["signout"].setEnabled(enabled == True)
318        self.menus["help"]["help"].setEnabled(enabled == True)
319
320    def createAction(self, text, slot, shortcut=None, checkable=False):
321        action = QtWidgets.QAction(text, self)
322        action.triggered.connect(slot)
323        if shortcut is not None:
324            action.setShortcut(shortcut)
325            self.addAction(action)
326        if checkable:
327            action.setCheckable(True)
328        return action
329
330    def normalize(self, url):
331        if url.endswith(".slack.com"):
332            url+= "/"
333        elif not url.endswith(".slack.com/"):
334            url = "https://"+url+".slack.com/"
335        return url
336
337    def current(self):
338        return self.stackedWidget.currentWidget()
339
340    def teams(self, teams):
341        if len(self.domains) == 0:
342            self.domains.append(teams[0]['team_url'])
343        team_list = [t['team_url'] for t in teams]
344        for t in teams:
345            for i in range(0, len(self.domains)):
346                self.domains[i] = self.normalize(self.domains[i])
347                # When team_icon is missing, the team already exists (Fixes #381, #391)
348                if 'team_icon' in t:
349                    if self.domains[i] in team_list:
350                        add = next(item for item in teams if item['team_url'] == self.domains[i])
351                        if 'team_icon' in add:
352                            self.leftPane.addTeam(add['id'], add['team_name'], add['team_url'], add['team_icon']['image_44'], add == teams[0])
353                            # Adding new teams and saving loading positions
354                            if t['team_url'] not in self.domains:
355                                self.leftPane.addTeam(t['id'], t['team_name'], t['team_url'], t['team_icon']['image_44'], t == teams[0])
356                                self.domains.append(t['team_url'])
357                                self.settings.setValue("Domain", self.domains)
358        if len(teams) > 1:
359            self.leftPane.show()
360
361    def switchTo(self, url):
362        exists = False
363        for i in range(0, self.stackedWidget.count()):
364            if self.stackedWidget.widget(i).url().toString().startswith(url):
365                self.stackedWidget.setCurrentIndex(i)
366                self.quicklist(self.current().listChannels())
367                self.current().setFocus()
368                self.leftPane.click(i)
369                self.clearMemory()
370                exists = True
371                break
372        if not exists:
373            self.addWrapper(url)
374
375    def eventFilter(self, obj, event):
376        if event.type() == QtCore.QEvent.ActivationChange and self.isActiveWindow():
377            self.focusInEvent(event)
378        if event.type() == QtCore.QEvent.KeyPress:
379            # Ctrl + <n>
380            modifiers = QtWidgets.QApplication.keyboardModifiers()
381            if modifiers == QtCore.Qt.ControlModifier:
382                if event.key() == QtCore.Qt.Key_1:   self.leftPane.click(0)
383                elif event.key() == QtCore.Qt.Key_2: self.leftPane.click(1)
384                elif event.key() == QtCore.Qt.Key_3: self.leftPane.click(2)
385                elif event.key() == QtCore.Qt.Key_4: self.leftPane.click(3)
386                elif event.key() == QtCore.Qt.Key_5: self.leftPane.click(4)
387                elif event.key() == QtCore.Qt.Key_6: self.leftPane.click(5)
388                elif event.key() == QtCore.Qt.Key_7: self.leftPane.click(6)
389                elif event.key() == QtCore.Qt.Key_8: self.leftPane.click(7)
390                elif event.key() == QtCore.Qt.Key_9: self.leftPane.click(8)
391                # Ctrl + Tab
392                elif event.key() == QtCore.Qt.Key_Tab: self.leftPane.clickNext(1)
393            # Ctrl + BackTab
394            if (modifiers & QtCore.Qt.ControlModifier) and (modifiers & QtCore.Qt.ShiftModifier):
395                if event.key() == QtCore.Qt.Key_Backtab: self.leftPane.clickNext(-1)
396            # Ctrl + Shift + <key>
397            if (modifiers & QtCore.Qt.ShiftModifier) and (modifiers & QtCore.Qt.ShiftModifier):
398                if event.key() == QtCore.Qt.Key_V: self.current().createSnippet()
399        return QtWidgets.QMainWindow.eventFilter(self, obj, event);
400
401    def focusInEvent(self, event):
402        self.launcher.set_property("urgent", False)
403        self.tray.stopAlert()
404        # Let's tickle all teams on window focus, but only if tickle was not fired in last 30 minutes
405        if DBusQtMainLoop is None and not self.tickler.isActive():
406            self.sendTickle()
407            self.tickler.start()
408
409    def titleChanged(self):
410        self.setWindowTitle(self.current().title())
411
412    def setForceClose(self):
413        self.forceClose = True
414
415    def closeEvent(self, event):
416        if not self.forceClose and self.settings.value("Systray") == "True":
417            self.hide()
418            event.ignore()
419        elif self.forceClose:
420            self.cookiesjar.save()
421            self.settings.setValue("Domain", self.domains)
422            self.settings.setValue("geometry", self.saveGeometry())
423            self.settings.setValue("windowState", self.saveState())
424            self.settings.setValue("Domain", self.domains)
425
426    def show(self):
427        self.setWindowState(self.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)
428        self.activateWindow()
429        self.setVisible(True)
430
431    def exit(self):
432        # Make sure tray is not visible (Fixes #513)
433        self.tray.setVisible(False)
434        self.setForceClose()
435        self.close()
436
437    def quicklist(self, channels):
438        if Dbusmenu is not None:
439            if channels is not None:
440                ql = Dbusmenu.Menuitem.new()
441                self.launcher.set_property("quicklist", ql)
442                for c in channels:
443                    if type(c) is dict and hasattr(c, '__getitem__') and c['is_member']:
444                        item = Dbusmenu.Menuitem.new ()
445                        item.property_set (Dbusmenu.MENUITEM_PROP_LABEL, "#"+c['name'])
446                        item.property_set ("id", c['name'])
447                        item.property_set_bool (Dbusmenu.MENUITEM_PROP_VISIBLE, True)
448                        item.connect(Dbusmenu.MENUITEM_SIGNAL_ITEM_ACTIVATED, self.current().openChannel)
449                        ql.child_append(item)
450                self.launcher.set_property("quicklist", ql)
451
452    def notify(self, title, message, icon):
453        if self.debug: print("Notification: title [{}] message [{}] icon [{}]".format(title, message, icon))
454        self.notifier.notify(title, message, icon)
455        self.alert()
456
457    def alert(self):
458        if not self.isActiveWindow():
459            self.launcher.set_property("urgent", True)
460            self.tray.alert()
461        if self.urgent_hint is True:
462            QApplication.alert(self)
463
464    def count(self):
465        total = 0
466        unreads = 0
467        for i in range(0, self.stackedWidget.count()):
468            widget = self.stackedWidget.widget(i)
469            highlights = widget.highlights
470            unreads+= widget.unreads
471            total+=highlights
472        if total > self.messages:
473            self.alert()
474        if 0 == total:
475            self.launcher.set_property("count_visible", False)
476            self.tray.setCounter(0)
477            if unreads > 0:
478                self.setWindowTitle("*{}".format(self.title))
479            else:
480                self.setWindowTitle(self.title)
481        else:
482            self.tray.setCounter(total)
483            self.launcher.set_property("count", total)
484            self.launcher.set_property("count_visible", True)
485            self.setWindowTitle("[{}]{}".format(str(total), self.title))
486        self.messages = total
487
488    def clearMemory(self):
489        QWebSettings.globalSettings().clearMemoryCaches()
490        QWebSettings.globalSettings().clearIconDatabase()
491