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