1import sys 2sys.coinit_flags= 0 3import win32com.client 4import pywintypes 5import os 6import shutil 7import socketserver 8import threading 9import asyncio 10import websockets 11import logging, logging.handlers 12import json 13import urllib 14import posixpath 15import time 16import pythoncom 17import pystray 18from PIL import Image 19from pystray._util import win32 20from copy import copy 21 22import ppt_control.__init__ as pkg_base 23import ppt_control.http_server_39 as http_server # 3.9 version of the HTTP server (details in module) 24import ppt_control.config as config 25 26logging.basicConfig() 27 28http_daemon = None 29my_http_server = None 30ws_daemon = None 31users = set() 32logger = None 33refresh_daemon = None 34icon = None 35ppt_application = None 36ppt_presentations = {} 37disable_protected_attempted = set() 38 39PKG_NAME = pkg_base.__name__ 40PKG_VERSION = pkg_base.__version__ 41DELAY_CLOSE = 0.2 42DELAY_PROTECTED = 0.5 43DELAY_FINAL = 0.1 44 45class MyIcon(pystray.Icon): 46 """ 47 Custom pystray.Icon class which displays menu when left-clicking on icon, as well as the 48 default right-click behaviour. 49 """ 50 def _on_notify(self, wparam, lparam): 51 """Handles ``WM_NOTIFY``. 52 If this is a left button click, this icon will be activated. If a menu 53 is registered and this is a right button click, the popup menu will be 54 displayed. 55 """ 56 if lparam == win32.WM_LBUTTONUP or ( 57 self._menu_handle and lparam == win32.WM_RBUTTONUP): 58 super()._on_notify(wparam, win32.WM_RBUTTONUP) 59 60class Handler(http_server.SimpleHTTPRequestHandler): 61 """ 62 Custom handler to translate /cache* urls to the cache directory (set in the config) 63 """ 64 def __init__(self, *args, **kwargs): 65 super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''') 66 67 def log_request(self, code='-', size='-'): 68 return 69 70 71 def translate_path(self, path): 72 """Translate a /-separated PATH to the local filename syntax. 73 74 Components that mean special things to the local file system 75 (e.g. drive or directory names) are ignored. (XXX They should 76 probably be diagnosed.) 77 78 """ 79 # abandon query parameters 80 path = path.split('?',1)[0] 81 path = path.split('#',1)[0] 82 # Don't forget explicit trailing slash when normalizing. Issue17324 83 trailing_slash = path.rstrip().endswith('/') 84 try: 85 path = urllib.parse.unquote(path, errors='surrogatepass') 86 except UnicodeDecodeError: 87 path = urllib.parse.unquote(path) 88 path = posixpath.normpath(path) 89 words = path.split('/') 90 words = list(filter(None, words)) 91 if len(words) > 0 and words[0] == "cache": 92 if words[1] in ppt_presentations: 93 path = config.prefs["Main"]["cache"] 94 else: 95 path = "black.jpg" 96 logger.warning("Request for cached file {} for non-existent presentation".format(path)) 97 words.pop(0) 98 else: 99 path = self.directory 100 for word in words: 101 if os.path.dirname(word) or word in (os.curdir, os.pardir): 102 # Ignore components that are not a simple file/directory name 103 continue 104 path = os.path.join(path, word) 105 if trailing_slash: 106 path += '/' 107 return path 108 109async def ws_handler(websocket, path): 110 """ 111 Handle a WebSockets connection 112 """ 113 logger.debug("Handling WebSocket connection") 114 recv_task = asyncio.ensure_future(ws_receive(websocket, path)) 115 send_task = asyncio.ensure_future(ws_send(websocket, path)) 116 done, pending = await asyncio.wait( 117 [recv_task, send_task], 118 return_when=asyncio.FIRST_COMPLETED, 119 ) 120 for task in pending: 121 task.cancel() 122 123async def ws_receive(websocket, path): 124 """ 125 Process data received on the WebSockets connection 126 """ 127 users.add(websocket) 128 try: 129 # Send initial state to clients on load 130 for pres in ppt_presentations: 131 broadcast_presentation(ppt_presentations[pres]) 132 async for message in websocket: 133 logger.debug("Received websocket message: " + str(message)) 134 data = json.loads(message) 135 if data["presentation"]: 136 pres = ppt_presentations[data["presentation"]] 137 else: 138 # Control last-initialised presentation if none specified (e.g. if using OBS script 139 # which doesn't have any visual feedback and hence no method to choose a 140 # presentation). This relies on any operations on the ppt_presentations dictionary 141 # being stable so that the order does not change. So far no problems have been 142 # detected with this, but it is not an ideal method. 143 pres = ppt_presentations[list(ppt_presentations.keys())[-1]] 144 if data["action"] == "prev": 145 pres.prev() 146 elif data["action"] == "next": 147 pres.next() 148 # Advancing to the black screen before the slideshow ends doesn't trigger 149 # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and 150 # broadcast the new state if necessary. A delay is required since the event is 151 # triggered before the slideshow is actually closed, and we don't want to attempt 152 # to check the current slide of a slideshow that isn't running. 153 time.sleep(DELAY_FINAL) 154 if (pres.get_slideshow() is not None and 155 pres.slide_current() == pres.slide_total() + 1): 156 logger.debug("Advanced to black slide before end") 157 broadcast_presentation(pres) 158 elif data["action"] == "first": 159 pres.first() 160 elif data["action"] == "last": 161 pres.last() 162 elif data["action"] == "black": 163 if pres.state() == 3 or ( 164 config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 4): 165 pres.normal() 166 else: 167 pres.black() 168 elif data["action"] == "white": 169 if pres.state() == 4 or ( 170 config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 3): 171 pres.normal() 172 else: 173 pres.white() 174 elif data["action"] == "goto": 175 pres.goto(int(data["value"])) 176 # Advancing to the black screen before the slideshow ends doesn't trigger 177 # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and 178 # broadcast the new state if necessary. A delay is required since the event is 179 # triggered before the slideshow is actually closed, and we don't want to attempt 180 # to check the current slide of a slideshow that isn't running. 181 time.sleep(DELAY_FINAL) 182 if (pres.get_slideshow() is not None and 183 pres.slide_current() == pres.slide_total() + 1): 184 logger.debug("Jumped to black slide before end") 185 broadcast_presentation(pres) 186 elif data["action"] == "start": 187 pres.start_slideshow() 188 elif data["action"] == "stop": 189 pres.stop_slideshow() 190 else: 191 logger.error("Received unnsupported event: {}", data) 192 finally: 193 users.remove(websocket) 194 195async def ws_send(websocket, path): 196 """ 197 Broadcast data to all WebSockets clients 198 """ 199 while True: 200 message = await ws_queue.get() 201 await asyncio.wait([user.send(message) for user in users]) 202 203 204def run_http(): 205 """ 206 Start the HTTP server 207 """ 208 global my_http_server 209 my_http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler) 210 my_http_server.serve_forever() 211 212 213def run_ws(): 214 """ 215 Set up threading/async for WebSockets server 216 """ 217 # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084 218 # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/ 219 pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED) 220 asyncio.set_event_loop(asyncio.new_event_loop()) 221 global ws_queue 222 ws_queue = asyncio.Queue() 223 global loop 224 loop = asyncio.get_event_loop() 225 start_server = websockets.serve(ws_handler, config.prefs["WebSocket"]["interface"], config.prefs.getint("WebSocket", "port"), ping_interval=None) 226 asyncio.get_event_loop().run_until_complete(start_server) 227 asyncio.get_event_loop().run_forever() 228 229def setup_http(): 230 """ 231 Set up threading for HTTP server 232 """ 233 http_daemon = threading.Thread(name="http_daemon", target=run_http) 234 http_daemon.setDaemon(True) 235 http_daemon.start() 236 logger.info("Started HTTP server") 237 238def setup_ws(): 239 """ 240 Set up threading for WebSockets server 241 """ 242 global ws_daemon 243 ws_daemon = threading.Thread(name="ws_daemon", target=run_ws) 244 ws_daemon.setDaemon(True) 245 ws_daemon.start() 246 logger.info("Started websocket server") 247 248 249def broadcast_presentation(pres): 250 """ 251 Broadcast the state of a single presentation to all connected clients. Also ensures the current 252 slide and the two upcoming slides are exported and cached. 253 """ 254 name = pres.presentation.Name 255 pres_open = name in ppt_presentations 256 slideshow = pres.slideshow is not None 257 visible = pres.state() 258 slide_current = pres.slide_current() 259 slide_total = pres.slide_total() 260 261 pres.export_current_next() 262 263 if users: # asyncio.wait doesn't accept an empty list 264 state = {"name": name, "pres_open": pres_open, "slideshow": slideshow, "visible": visible, 265 "slide_current": slide_current, "slide_total": slide_total} 266 loop.call_soon_threadsafe(ws_queue.put_nowait, json.dumps({"type": "state", **state})) 267 268class ApplicationEvents: 269 """ 270 Events assigned to the root application. 271 Ref: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application#events 272 """ 273 def OnSlideShowNextSlide(self, window, *args): 274 """ 275 Triggered when the current slide number of any slideshow is incremented, locally or through 276 ppt_control. 277 """ 278 logger.debug("Slide advanced for {}".format(window.Presentation.Name)) 279 broadcast_presentation(ppt_presentations[window.Presentation.Name]) 280 281 def OnSlideShowPrevSlide(self, window, *args): 282 """ 283 Triggered when the current slide number of any slideshow is decremented, locally or through 284 ppt_control. 285 """ 286 logger.debug("Slide decremented for {}".format(window.Presentation.Name)) 287 broadcast_presentation(ppt_presentations[window.Presentation.Name]) 288 289 def OnAfterPresentationOpen(self, presentation, *args): 290 """ 291 Triggered when an existing presentation is opened. This adds the newly opened presentation 292 to the list of open presentations. 293 """ 294 logger.debug("Presentation {} opened - adding to list".format(presentation.Name)) 295 global ppt_presentations 296 ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation) 297 broadcast_presentation(ppt_presentations[presentation.Name]) 298 disable_protected_attempted.discard(presentation.Name) 299 icon.notify("Connected to {}".format(presentation.Name), PKG_NAME) 300 301 def OnAfterNewPresentation(self, presentation, *args): 302 """ 303 Triggered when a new presentation is opened. This adds the new presentation to the list 304 of open presentations. 305 """ 306 logger.debug("Presentation {} opened (blank) - adding to list".format(presentation.Name)) 307 global ppt_presentations 308 ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation) 309 broadcast_presentation(ppt_presentations[presentation.Name]) 310 icon.notify("Connected to {}".format(presentation.Name), PKG_NAME) 311 312 def OnPresentationClose(self, presentation, *args): 313 """ 314 Triggered when a presentation is closed. This removes the presentation from the list of 315 open presentations. A delay is included to make sure the presentation is 316 actually closed, since the event is called simultaneously as the presentation is removed 317 from PowerPoint's internal structure. Ref: 318 https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application.presentationclose 319 """ 320 logger.debug("Presentation {} closed - removing from list".format(presentation.Name)) 321 global ppt_presentations 322 time.sleep(DELAY_CLOSE) 323 broadcast_presentation(ppt_presentations.pop(presentation.Name)) 324 icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME) 325 326 def OnSlideShowBegin(self, window, *args): 327 """ 328 Triggered when a slideshow is started. This initialises the Slideshow object in the 329 appropriate Presentation object. 330 """ 331 logger.debug("Slideshow started for {}".format(window.Presentation.Name)) 332 global ppt_presentations 333 ppt_presentations[window.Presentation.Name].slideshow = window 334 broadcast_presentation(ppt_presentations[window.Presentation.Name]) 335 336 def OnSlideShowEnd(self, presentation, *args): 337 """ 338 Triggered when a slideshow is ended. This deinitialises the Slideshow object in the 339 appropriate Presentation object. 340 """ 341 logger.debug("Slideshow ended for {}".format(presentation.Name)) 342 global ppt_presentations 343 ppt_presentations[presentation.Name].slideshow = None 344 broadcast_presentation(ppt_presentations[presentation.Name]) 345 346 347class Presentation: 348 """ 349 Class representing an instance of PowerPoint with a file open (so-called "presentation" 350 in PowerPoint terms). Mostly just a wrapper for PowerPoint's `Presentation` object. 351 """ 352 def __init__(self, application, pres_index=None, pres_obj=None): 353 """ 354 Initialise a Presentation object. 355 application The PowerPoint application which the presentation is being run within 356 pres_index PowerPoint's internal presentation index (NOTE this is indexed from 1) 357 """ 358 if pres_index == None and pres_obj == None: 359 raise ValueError("Cannot initialise a presentation without a presentation ID or object") 360 assert len(application.Presentations) > 0, "Cannot initialise presentation from application with no presentations" 361 362 self.__application = application 363 if pres_obj is not None: 364 self.presentation = pres_obj 365 else: 366 self.presentation = application.Presentations(pres_index) 367 self.slideshow = self.get_slideshow() 368 369 370 def get_slideshow(self): 371 """ 372 Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow. 373 """ 374 try: 375 return self.presentation.SlideShowWindow 376 except pywintypes.com_error as exc: 377 logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc)) 378 return None 379 380 def start_slideshow(self): 381 """ 382 Start the slideshow. Updating the state of this object is managed by the OnSlideshowBegin 383 event of the applicable Application. 384 """ 385 if self.get_slideshow() is None: 386 self.presentation.SlideShowSettings.Run() 387 else: 388 logger.warning("Cannot start slideshow that is already running (presentation {})".format( 389 self.presentation.Name)) 390 391 def stop_slideshow(self): 392 """ 393 Stop the slideshow. Updating the state of this object is managed by the OnSlideshowEnd 394 event of the applicable Application. 395 """ 396 if self.get_slideshow() is not None: 397 self.presentation.SlideShowWindow.View.Exit() 398 else: 399 logger.warning("Cannot stop slideshow that is not running (presentation {})".format( 400 self.presentation.Name)) 401 402 def state(self): 403 """ 404 Returns the visibility state of the slideshow: 405 1: running 406 2: paused 407 3: black 408 4: white 409 5: done 410 Source: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate 411 """ 412 if self.slideshow is not None: 413 return self.slideshow.View.State 414 else: 415 return 0 416 417 def slide_current(self): 418 """ 419 Returns the current slide number of the slideshow, or 0 if no slideshow is running. 420 """ 421 if self.slideshow is not None: 422 return self.slideshow.View.CurrentShowPosition 423 else: 424 return 0 425 426 def slide_total(self): 427 """ 428 Returns the total number of slides in the presentation, regardless of whether a slideshow 429 is running. 430 """ 431 return self.presentation.Slides.Count 432 433 def prev(self): 434 """ 435 Go to the previous slide if there is a slideshow running. Notifying clients of the new state 436 is managed by the ApplicationEvent. 437 """ 438 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 439 self.slideshow.View.Previous() 440 441 def next(self): 442 """ 443 Go to the previous slide if there is a slideshow running. Notifying clients of the new state 444 is managed by the ApplicationEvent. 445 """ 446 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 447 self.slideshow.View.Next() 448 449 def first(self): 450 """ 451 Go to the first slide if there is a slideshow running. Notifying clients of the new state 452 is managed by the ApplicationEvent. 453 """ 454 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 455 self.slideshow.View.First() 456 457 def last(self): 458 """ 459 Go to the last slide if there is a slideshow running. Notifying clients of the new state 460 is managed by the ApplicationEvent. 461 """ 462 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 463 self.slideshow.View.Last() 464 465 def goto(self, slide): 466 """ 467 Go to a numbered slide if there is a slideshow running. Notifying clients of the new state 468 is managed by the ApplicationEvent. 469 """ 470 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 471 if slide <= self.slide_total(): 472 self.slideshow.View.GotoSlide(slide) 473 else: 474 self.last() 475 self.next() 476 477 def normal(self): 478 """ 479 Make the slideshow visible if there is a slideshow running. Note this puts the slideshow into 480 "running" state rather than the normal "paused" to ensure animations work correctly and the 481 slide is actually visible after changing the state. The state is normally returned to 482 "paused" automatically by PPT when advancing to the following slide. State enumeration ref: 483 https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate 484 """ 485 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 486 self.slideshow.View.State = 1 487 broadcast_presentation(self) 488 489 def black(self): 490 """ 491 Make the slideshow black if there is a slideshow running. 492 """ 493 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 494 self.slideshow.View.State = 3 495 broadcast_presentation(self) 496 497 def white(self): 498 """ 499 Make the slideshow white if there is a slideshow running. 500 """ 501 assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name) 502 self.slideshow.View.State = 4 503 broadcast_presentation(self) 504 505 def export_current_next(self): 506 """ 507 Export the current slide, the next slide, and the one after (ensures enough images are 508 always cached) 509 """ 510 self.export(self.slide_current()) 511 self.export(self.slide_current() + 1) 512 self.export(self.slide_current() + 2) 513 514 def export(self, slide): 515 """ 516 Export a relatively low-resolution image of a slide using PowerPoint's built-in export 517 function. The cache destination is set in the config. 518 """ 519 destination = config.prefs["Main"]["cache"] + "\\" + self.presentation.Name + "\\" + str(slide) + "." + config.prefs["Main"]["cache_format"].lower() 520 logger.debug("Exporting slide {} of {}".format(slide, self.presentation.Name)) 521 os.makedirs(os.path.dirname(destination), exist_ok=True) 522 if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"): 523 if slide <= self.slide_total(): 524 attempts = 0 525 while attempts < 3: 526 try: 527 self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"]) 528 break 529 except: 530 pass 531 attempts += 1 532 elif slide == self.slide_total() + 1: 533 try: 534 shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb')) 535 except Exception as exc: 536 logger.warning("Failed to copy black slide (number {} for presentation {}): {}".format(slide, self.presentation.Name, exc)) 537 else: 538 pass 539 540 def export_all(self): 541 """ 542 Export all slides in the presentation 543 """ 544 for i in range(1, self.slide_total() + 2): 545 self.export(i) 546 547def null_action(*args): 548 """ 549 Placeholder for disabled menu items in systray 550 """ 551 pass 552 553def edit_config(*args): 554 """ 555 Open the config file for editing in Notepad, and create the directory if not existing 556 """ 557 logger.debug("Opening config {}".format(pkg_base.CONFIG_PATH)) 558 if not os.path.exists(pkg_base.CONFIG_DIR): 559 try: 560 os.makedirs(pkg_base.CONFIG_DIR) 561 logger.info("Made directory {}".format(pkg_base.CONFIG_DIR)) 562 except Exception as exc: 563 logger.warning("Failed to create directory {} for config file".format( 564 pkg_base.CONFIG_DIR)) 565 icon.notify("Create {} manually".format((pkg_base.CONFIG_DIR[:40] + '...') 566 if len(pkg_base.CONFIG_DIR) > 40 else pkg_base.CONFIG_DIR), 567 "Failed to create config directory") 568 try: 569 os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH)) 570 except Exception as exc: 571 logger.warning("Failed to edit config {}: {}".format(pkg_base.CONFIG_PATH, exc)) 572 icon.notify("Edit {} manually".format((pkg_base.CONFIG_PATH[:40] + '...') 573 if len(pkg_base.CONFIG_PATH) > 40 else pkg_base.CONFIG_PATH), 574 "Failed to open config") 575 576def refresh(): 577 """ 578 Clear COM events and update interface elements at an interval defined in "refresh" in the config 579 TODO: fix "argument of type 'com_error' is not iterable" 580 """ 581 while getattr(refresh_daemon, "do_run", True): 582 try: 583 pythoncom.PumpWaitingMessages() 584 # TODO: don't regenerate entire menu on each refresh, use pystray.Icon.update_menu() 585 icon.menu = (pystray.MenuItem("Status: " + "dis"*(len(ppt_presentations) == 0) + "connected", 586 lambda: null_action(), enabled=False), 587 pystray.MenuItem("Stop", lambda: exit(icon)), 588 pystray.MenuItem("Edit config", lambda: edit_config())) 589 manage_protected_view(ppt_application) 590 time.sleep(float(config.prefs["Main"]["refresh"])) 591 except Exception as exc: 592 # Deal with any exceptions, such as RPC server restarting, by reconnecting to application 593 # (if this fails again, that's okay because we'll keep trying until it works) 594 logger.error("Error whilst refreshing state: {}".format(exc)) 595 app = get_application() 596 597 598def get_application(): 599 """ 600 Create an Application object representing the PowerPoint application installed on the machine. 601 This should succeed regardless of whether PowerPoint is running, as long as PowerPoint is 602 installed. 603 Returns the Application object if successful, otherwise returns None. 604 """ 605 try: 606 return win32com.client.Dispatch('PowerPoint.Application') 607 except pywintypes.com_error: 608 # PowerPoint is probably not installed, or other COM failure 609 return None 610 611 612def manage_protected_view(app): 613 """ 614 Attempt to unlock any presentations that have been opened in protected view. These cannot be 615 controlled by the program whilst they are in protected view, so we attempt to disable protected 616 view, or show a notification if this doesn't work for some reason. 617 """ 618 try: 619 if app.ProtectedViewWindows.Count > 0: 620 logger.debug("Found open presentation(s) but at least one is in protected view") 621 for i in range(1, app.ProtectedViewWindows.Count + 1): # +1 to account for indexing from 1 622 pres_name = app.ProtectedViewWindows(i).Presentation.Name 623 if pres_name in disable_protected_attempted: 624 continue 625 if config.prefs.getboolean("Main", "disable_protected"): 626 try: 627 app.ProtectedViewWindows(i).Edit() 628 logger.info("Enabled editing for {}".format(pres_name)) 629 except Exception as exc: 630 icon.notify("Failed to disable protected view on \"{}\"".format((pres_name[:22] + '...') 631 if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint") 632 logger.warning("Failed to disable protected view {} for editing - do this manually: {}".format(pres_name, exc)) 633 disable_protected_attempted.add(pres_name) 634 else: 635 icon.notify("Cannot control \"{}\" in protected view".format((pres_name[:22] + '...') 636 if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint") 637 logger.warning("Cannot control {} in protected view, and automatic disabling of protected view is turned off".format(pres_name)) 638 disable_protected_attempted.add(pres_name) 639 except Exception as exc: 640 if type(exc) == pywintypes.com_error and "application is busy" in exc: 641 # PowerPoint needs some time to finish loading file if it has just been opened, 642 # otherwise we get "The message filter indicated that the application is busy". Here, 643 # we deal with this by gracefully ignoring any protected view windows until the next 644 # refresh cycle, when PowerPoint is hopefully finished loading (if the refresh interval 645 # is sufficiently long). 646 logger.debug("COM interface not taking requests right now - will try again on next refresh") 647 return 648 # Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients" 649 # at this point. 650 logger.warning("{} whilst dealing with protected view windows: {}".format(type(exc), exc)) 651 app = get_application() 652 653 654 655def connect_ppt(): 656 """ 657 Connect to the PowerPoint COM interface and perform initial enumeration of open files 658 ("presentations"). Files that are subsequently opened are dealt with using COM events (see the 659 ApplicationEvents class above). Therefore, once we are finished setting things up, we just 660 call refresh() as a daemon in order to keep clients up to date. 661 """ 662 logger.debug("Searching for a PowerPoint slideshow...") 663 global ppt_application 664 global ppt_presentations 665 666 # Initialise PowerPoint application 667 ppt_application = get_application() 668 if ppt_application is None: 669 # Couldn't find PowerPoint application 670 icon.notify("Couldn't find PowerPoint application", "Error starting {}".format(PKG_NAME)) 671 logger.error("Couldn't find PowerPoint application - check that PowerPoint is installed and COM is working") 672 sys.exit() 673 674 # Continue because we can connect to PowerPoint 675 logger.debug("Found PowerPoint application") 676 677 # Dispatch events 678 win32com.client.WithEvents(ppt_application, ApplicationEvents) 679 logger.debug("Dispatched events") 680 681 # Deal with windows in protected view 682 manage_protected_view(ppt_application) 683 684 # Initial enumeration of open presentations 685 logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations))) 686 for n in range(1, len(ppt_application.Presentations)+1): # PowerPoint's slide indexing starts at 1.. why!?!?!? 687 pres = Presentation(ppt_application, n) 688 icon.notify("Connected to {}".format(pres.presentation.Name), PKG_NAME) 689 logger.debug("Found presentation {} with index {}".format(pres.presentation.Name, n)) 690 ppt_presentations[pres.presentation.Name] = pres 691 refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh) 692 refresh_daemon.setDaemon(True) 693 logger.debug("Handing over to refresh daemon - goodbye...") 694 if len(ppt_presentations) == 0: 695 # Provide some confirmation that the program has started if we haven't sent any 696 # connection notifications yet 697 icon.notify("Started server", PKG_NAME) 698 refresh_daemon.start() 699 700 701def start_server(_=None): 702 """ 703 Start HTTP and WS servers, then connect to PPT instance with connect_ppt() which will then 704 set off the refresh daemon. 705 """ 706 setup_http() 707 setup_ws() 708 connect_ppt() 709 710 711def exit(icon): 712 """ 713 Clean up and exit when user clicks "Stop" from systray menu 714 """ 715 logger.debug("User requested shutdown") 716 icon.visible = False 717 icon.stop() 718 719 720def start_interface(): 721 """ 722 Main entrypoint for the program. Loads config and logging, starts systray icon, and calls 723 start_server() to start the backend. 724 """ 725 global icon 726 global logger 727 # Load config 728 config.prefs = config.loadconf(pkg_base.CONFIG_PATH) 729 730 # Set up logging 731 if config.prefs["Main"]["logging"] == "debug": 732 log_level = logging.DEBUG 733 elif config.prefs["Main"]["logging"] == "info": 734 log_level = logging.CRITICAL 735 else: 736 log_level = logging.WARNING 737 log_level = logging.DEBUG 738 739 log_formatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] [%(levelname)-7.7s] %(message)s") 740 logger = logging.getLogger("ppt-control") 741 logger.setLevel(log_level) 742 logger.propagate = False 743 744 console_handler = logging.StreamHandler() 745 console_handler.setFormatter(log_formatter) 746 console_handler.setLevel(log_level) 747 logger.addHandler(console_handler) 748 749 if not os.path.exists(pkg_base.LOG_DIR): 750 try: 751 os.makedirs(pkg_base.LOG_DIR) 752 logger.info("Made directory {}".format(pkg_base.LOG_DIR)) 753 except Exception as exc: 754 logger.warning("Failed to create directory {} for log".format( 755 pkg_base.LOG_DIR)) 756 icon.notify("Create {} manually".format((pkg_base.LOG_DIR[:40] + '...') 757 if len(pkg_base.LOG_DIR) > 40 else pkg_base.LOG_DIR), 758 "Failed to create log directory") 759 file_handler = logging.FileHandler(pkg_base.LOG_PATH) 760 file_handler.setFormatter(log_formatter) 761 file_handler.setLevel(log_level) 762 logger.addHandler(file_handler) 763 764 #logging.getLogger("asyncio").setLevel(logging.ERROR) 765 #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR) 766 logging.getLogger("websockets.server").setLevel(logging.ERROR) 767 #logging.getLogger("websockets.protocol").setLevel(logging.ERROR) 768 769 770 logger.debug("Finished setting up config and logging") 771 772 # Start systray icon and server 773 logger.debug("Starting system tray icon") 774 icon = MyIcon(PKG_NAME) 775 icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''') 776 icon.title = "{} {}".format(PKG_NAME, PKG_VERSION) 777 #refresh_menu(icon) 778 icon.visible = True 779 icon.run(setup=start_server) 780 781 # Exit when icon has stopped 782 sys.exit(0) 783 784if __name__ == "__main__": 785 start_interface()