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 54classHandler(http_server.SimpleHTTPRequestHandler): 55""" 56 Custom handler to translate /cache* urls to the cache directory (set in the config) 57 """ 58def__init__(self, *args, **kwargs): 59super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''') 60 61deflog_request(self, code='-', size='-'): 62return 63 64 65deftranslate_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('/') 78try: 79 path = urllib.parse.unquote(path, errors='surrogatepass') 80exceptUnicodeDecodeError: 81 path = urllib.parse.unquote(path) 82 path = posixpath.normpath(path) 83 words = path.split('/') 84 words =list(filter(None, words)) 85iflen(words) >0and words[0] =="cache": 86if words[1]in ppt_presentations: 87 path = config.prefs["Main"]["cache"] 88else: 89 path ="black.jpg" 90 logger.warning("Request for cached file {} for non-existent presentation".format(path)) 91 words.pop(0) 92else: 93 path = self.directory 94for word in words: 95if os.path.dirname(word)or word in(os.curdir, os.pardir): 96# Ignore components that are not a simple file/directory name 97continue 98 path = os.path.join(path, word) 99if trailing_slash: 100 path +='/' 101return path 102 103async defws_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) 114for task in pending: 115 task.cancel() 116 117async defws_receive(websocket, path): 118""" 119 Process data received on the WebSockets connection 120 """ 121 users.add(websocket) 122try: 123# Send initial state to clients on load 124for pres in ppt_presentations: 125broadcast_presentation(ppt_presentations[pres]) 126async for message in websocket: 127 logger.debug("Received websocket message: "+str(message)) 128 data = json.loads(message) 129 pres = ppt_presentations[data["presentation"]] 130if data["action"] =="prev": 131 pres.prev() 132elif data["action"] =="next": 133 pres.next() 134elif data["action"] =="first": 135 pres.first() 136elif data["action"] =="last": 137 pres.last() 138elif data["action"] =="black": 139if pres.state() ==3: 140 pres.normal() 141else: 142 pres.black() 143elif data["action"] =="white": 144if pres.state() ==4: 145 pres.normal() 146else: 147 pres.white() 148elif data["action"] =="goto": 149 pres.goto(int(data["value"])) 150else: 151 logger.error("Received unnsupported event: {}", data) 152finally: 153 users.remove(websocket) 154 155async defws_send(websocket, path): 156""" 157 Broadcast data to all WebSockets clients 158 """ 159while True: 160 message =await ws_queue.get() 161await asyncio.wait([user.send(message)for user in users]) 162 163 164defrun_http(): 165""" 166 Start the HTTP server 167 """ 168global 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 173defrun_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()) 181global ws_queue 182 ws_queue = asyncio.Queue() 183global 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 189defsetup_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 198defsetup_ws(): 199""" 200 Set up threading for WebSockets server 201 """ 202global 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 209defbroadcast_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 223if 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 """ 233defOnSlideShowNextSlide(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)) 239broadcast_presentation(ppt_presentations[window.Presentation.Name]) 240 241defOnSlideShowPrevSlide(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)) 247broadcast_presentation(ppt_presentations[window.Presentation.Name]) 248 249defOnAfterPresentationOpen(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)) 255global ppt_presentations 256 ppt_presentations[presentation.Name] =Presentation(ppt_application, pres_obj=presentation) 257broadcast_presentation(ppt_presentations[presentation.Name]) 258 disable_protected_attempted.discard(presentation.Name) 259 icon.notify("Connected to {}".format(presentation.Name), PKG_NAME) 260 261defOnAfterNewPresentation(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)) 267global ppt_presentations 268 ppt_presentations[presentation.Name] =Presentation(ppt_application, pres_obj=presentation) 269broadcast_presentation(ppt_presentations[presentation.Name]) 270 icon.notify("Connected to {}".format(presentation.Name), PKG_NAME) 271 272defOnPresentationClose(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)) 281global ppt_presentations 282 time.sleep(0.2) 283broadcast_presentation(ppt_presentations.pop(presentation.Name)) 284 icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME) 285 286defOnSlideShowBegin(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)) 292global ppt_presentations 293 ppt_presentations[window.Presentation.Name].slideshow = window 294broadcast_presentation(ppt_presentations[window.Presentation.Name]) 295 296defOnSlideShowEnd(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)) 302global ppt_presentations 303 ppt_presentations[presentation.Name].slideshow =None 304broadcast_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 """ 312def__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 """ 318if pres_index ==None and pres_obj ==None: 319raiseValueError("Cannot initialise a presentation without a presentation ID or object") 320assertlen(application.Presentations) >0,"Cannot initialise presentation from application with no presentations" 321 322 self.__application = application 323if pres_obj is not None: 324 self.presentation = pres_obj 325else: 326 self.presentation = application.Presentations(pres_index) 327 self.slideshow = self.get_slideshow() 328 329 330defget_slideshow(self): 331""" 332 Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow. 333 """ 334try: 335return self.presentation.SlideShowWindow 336except pywintypes.com_error as exc: 337 logger.debug("Couldn't get slideshow for{}: {}".format(self.presentation.Name, exc)) 338return None 339 340defstate(self): 341""" 342 Returns the visibility state of the slideshow: 343 2: normal 344 3: black 345 4: white 346 5: done 347 """ 348if self.slideshow is not None: 349return self.slideshow.View.State 350else: 351return2 352 353defslide_current(self): 354""" 355 Returns the current slide number of the slideshow, or 0 if no slideshow is running. 356 """ 357if self.slideshow is not None: 358return self.slideshow.View.CurrentShowPosition 359else: 360return0 361 362defslide_total(self): 363""" 364 Returns the total number of slides in the presentation, regardless of whether a slideshow 365 is running. 366 """ 367return self.presentation.Slides.Count 368 369defprev(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 """ 374assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 375 self.slideshow.View.Previous() 376 377defnext(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 """ 382assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 383 self.slideshow.View.Next() 384 385deffirst(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 """ 390assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 391 self.slideshow.View.First() 392 393deflast(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 """ 398assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 399 self.slideshow.View.Last() 400 401defgoto(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 """ 406assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 407if slide <= self.slide_total(): 408 self.slideshow.View.GotoSlide(slide) 409else: 410 self.last() 411 self.next() 412 413defnormal(self): 414""" 415 Make the slideshow visible if there is a slideshow running. 416 """ 417assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 418 self.slideshow.View.State =2 419broadcast_presentation(self) 420 421defblack(self): 422""" 423 Make the slideshow black if there is a slideshow running. 424 """ 425assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 426 self.slideshow.View.State =3 427broadcast_presentation(self) 428 429defwhite(self): 430""" 431 Make the slideshow white if there is a slideshow running. 432 """ 433assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 434 self.slideshow.View.State =4 435broadcast_presentation(self) 436 437defexport_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 446defexport(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) 454if not os.path.exists(destination)or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main","cache_timeout"): 455if slide <= self.slide_total(): 456 attempts =0 457while attempts <3: 458try: 459 self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"]) 460break 461except: 462pass 463 attempts +=1 464elif slide == self.slide_total() +1: 465try: 466 shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''','rb'),open(destination,'wb')) 467exceptExceptionas exc: 468 logger.warning("Failed to copy black slide (number{} for presentation {}): {}".format(slide, self.presentation.Name, exc)) 469else: 470pass 471 472defexport_all(self): 473""" 474 Export all slides in the presentation 475 """ 476for i inrange(1, self.slide_total() +2): 477 self.export(i) 478 479defnull_action(*args): 480""" 481 Placeholder for disabled menu items in systray 482 """ 483pass 484 485defrefresh(): 486""" 487 Clear COM events and update interface elements at an interval defined in "refresh" in the config 488 """ 489whilegetattr(refresh_daemon,"do_run",True): 490try: 491 pythoncom.PumpWaitingMessages() 492 icon.menu = (pystray.MenuItem("Status: "+"dis"*(len(ppt_presentations) ==0) +"connected", 493lambda:null_action(), enabled=False), 494 pystray.MenuItem("Stop",lambda:exit(icon))) 495manage_protected_view(ppt_application) 496 time.sleep(float(config.prefs["Main"]["refresh"])) 497exceptExceptionas 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 505defget_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 """ 512try: 513return win32com.client.Dispatch('PowerPoint.Application') 514except pywintypes.com_error: 515# PowerPoint is probably not installed, or other COM failure 516return None 517 518 519defmanage_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 """ 525try: 526if app.ProtectedViewWindows.Count >0: 527 logger.debug("Found open presentation(s) but at least one is in protected view") 528for i inrange(1, app.ProtectedViewWindows.Count +1):# +1 to account for indexing from 1 529 pres_name = app.ProtectedViewWindows(i).Presentation.Name 530if pres_name in disable_protected_attempted: 531continue 532if config.prefs.getboolean("Main","disable_protected"): 533try: 534 app.ProtectedViewWindows(i).Edit() 535 logger.info("Enabled editing for {}".format(pres_name)) 536exceptExceptionas exc: 537 icon.notify("Failed to disable protected view on\"{}\"".format((pres_name[:22] +'...') 538iflen(pres_name) >25else 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) 541else: 542 icon.notify("Cannot control\"{}\"in protected view".format((pres_name[:22] +'...') 543iflen(pres_name) >25else 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) 546exceptExceptionas 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 554defconnect_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...") 562global ppt_application 563global ppt_presentations 564 565# Initialise PowerPoint application 566 ppt_application =get_application() 567if 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 581manage_protected_view(ppt_application) 582 583# Initial enumeration of open presentations 584 logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations))) 585for n inrange(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...") 593iflen(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 600defstart_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 """ 605setup_http() 606setup_ws() 607connect_ppt() 608 609 610defexit(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 619defstart_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 """ 624global icon 625global logger 626# Load config 627 config.prefs = config.loadconf(CONFIG_FILE) 628 629# Set up logging 630if config.prefs["Main"]["logging"] =="debug": 631 log_level = logging.DEBUG 632elif config.prefs["Main"]["logging"] =="info": 633 log_level = logging.CRITICAL 634else: 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__": 674start_interface()