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