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 45classMyIcon(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 """ 50def_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 """ 56if lparam == win32.WM_LBUTTONUP or( 57 self._menu_handle and lparam == win32.WM_RBUTTONUP): 58super()._on_notify(wparam, win32.WM_RBUTTONUP) 59 60classHandler(http_server.SimpleHTTPRequestHandler): 61""" 62 Custom handler to translate /cache* urls to the cache directory (set in the config) 63 """ 64def__init__(self, *args, **kwargs): 65super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''') 66 67deflog_request(self, code='-', size='-'): 68return 69 70 71deftranslate_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('/') 84try: 85 path = urllib.parse.unquote(path, errors='surrogatepass') 86exceptUnicodeDecodeError: 87 path = urllib.parse.unquote(path) 88 path = posixpath.normpath(path) 89 words = path.split('/') 90 words =list(filter(None, words)) 91iflen(words) >0and words[0] =="cache": 92if words[1]in ppt_presentations: 93 path = config.prefs["Main"]["cache"] 94else: 95 path ="black.jpg" 96 logger.warning("Request for cached file {} for non-existent presentation".format(path)) 97 words.pop(0) 98else: 99 path = self.directory 100for word in words: 101if os.path.dirname(word)or word in(os.curdir, os.pardir): 102# Ignore components that are not a simple file/directory name 103continue 104 path = os.path.join(path, word) 105if trailing_slash: 106 path +='/' 107return path 108 109async defws_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) 120for task in pending: 121 task.cancel() 122 123async defws_receive(websocket, path): 124""" 125 Process data received on the WebSockets connection 126 """ 127 users.add(websocket) 128try: 129# Send initial state to clients on load 130for pres in ppt_presentations: 131broadcast_presentation(ppt_presentations[pres]) 132async for message in websocket: 133 logger.debug("Received websocket message: "+str(message)) 134 data = json.loads(message) 135if data["presentation"]: 136 pres = ppt_presentations[data["presentation"]] 137else: 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]] 144if data["action"] =="prev": 145 pres.prev() 146elif 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) 154if(pres.get_slideshow()is not None and 155 pres.slide_current() == pres.slide_total() +1): 156 logger.debug("Advanced to black slide before end") 157broadcast_presentation(pres) 158elif data["action"] =="first": 159 pres.first() 160elif data["action"] =="last": 161 pres.last() 162elif data["action"] =="black": 163if pres.state() ==3or( 164 config.prefs["Main"]["blackwhite"] =="both"and pres.state() ==4): 165 pres.normal() 166else: 167 pres.black() 168elif data["action"] =="white": 169if pres.state() ==4or( 170 config.prefs["Main"]["blackwhite"] =="both"and pres.state() ==3): 171 pres.normal() 172else: 173 pres.white() 174elif 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) 182if(pres.get_slideshow()is not None and 183 pres.slide_current() == pres.slide_total() +1): 184 logger.debug("Jumped to black slide before end") 185broadcast_presentation(pres) 186elif data["action"] =="start": 187 pres.start_slideshow() 188elif data["action"] =="stop": 189 pres.stop_slideshow() 190else: 191 logger.error("Received unnsupported event: {}", data) 192finally: 193 users.remove(websocket) 194 195async defws_send(websocket, path): 196""" 197 Broadcast data to all WebSockets clients 198 """ 199while True: 200 message =await ws_queue.get() 201await asyncio.wait([user.send(message)for user in users]) 202 203 204defrun_http(): 205""" 206 Start the HTTP server 207 """ 208global 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 213defrun_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()) 221global ws_queue 222 ws_queue = asyncio.Queue() 223global 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 229defsetup_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 238defsetup_ws(): 239""" 240 Set up threading for WebSockets server 241 """ 242global 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 249defbroadcast_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 263if 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 """ 273defOnSlideShowNextSlide(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)) 279broadcast_presentation(ppt_presentations[window.Presentation.Name]) 280 281defOnSlideShowPrevSlide(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)) 287broadcast_presentation(ppt_presentations[window.Presentation.Name]) 288 289defOnAfterPresentationOpen(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)) 295global ppt_presentations 296 ppt_presentations[presentation.Name] =Presentation(ppt_application, pres_obj=presentation) 297broadcast_presentation(ppt_presentations[presentation.Name]) 298 disable_protected_attempted.discard(presentation.Name) 299 icon.notify("Connected to {}".format(presentation.Name), PKG_NAME) 300 301defOnAfterNewPresentation(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)) 307global ppt_presentations 308 ppt_presentations[presentation.Name] =Presentation(ppt_application, pres_obj=presentation) 309broadcast_presentation(ppt_presentations[presentation.Name]) 310 icon.notify("Connected to {}".format(presentation.Name), PKG_NAME) 311 312defOnPresentationClose(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)) 321global ppt_presentations 322 time.sleep(DELAY_CLOSE) 323broadcast_presentation(ppt_presentations.pop(presentation.Name)) 324 icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME) 325 326defOnSlideShowBegin(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)) 332global ppt_presentations 333 ppt_presentations[window.Presentation.Name].slideshow = window 334broadcast_presentation(ppt_presentations[window.Presentation.Name]) 335 336defOnSlideShowEnd(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)) 342global ppt_presentations 343 ppt_presentations[presentation.Name].slideshow =None 344broadcast_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 """ 352def__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 """ 358if pres_index ==None and pres_obj ==None: 359raiseValueError("Cannot initialise a presentation without a presentation ID or object") 360assertlen(application.Presentations) >0,"Cannot initialise presentation from application with no presentations" 361 362 self.__application = application 363if pres_obj is not None: 364 self.presentation = pres_obj 365else: 366 self.presentation = application.Presentations(pres_index) 367 self.slideshow = self.get_slideshow() 368 369 370defget_slideshow(self): 371""" 372 Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow. 373 """ 374try: 375return self.presentation.SlideShowWindow 376except pywintypes.com_error as exc: 377 logger.debug("Couldn't get slideshow for{}: {}".format(self.presentation.Name, exc)) 378return None 379 380defstart_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 """ 385if self.get_slideshow()is None: 386 self.presentation.SlideShowSettings.Run() 387else: 388 logger.warning("Cannot start slideshow that is already running (presentation {})".format( 389 self.presentation.Name)) 390 391defstop_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 """ 396if self.get_slideshow()is not None: 397 self.presentation.SlideShowWindow.View.Exit() 398else: 399 logger.warning("Cannot stop slideshow that is not running (presentation {})".format( 400 self.presentation.Name)) 401 402defstate(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 """ 412if self.slideshow is not None: 413return self.slideshow.View.State 414else: 415return0 416 417defslide_current(self): 418""" 419 Returns the current slide number of the slideshow, or 0 if no slideshow is running. 420 """ 421if self.slideshow is not None: 422return self.slideshow.View.CurrentShowPosition 423else: 424return0 425 426defslide_total(self): 427""" 428 Returns the total number of slides in the presentation, regardless of whether a slideshow 429 is running. 430 """ 431return self.presentation.Slides.Count 432 433defprev(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 """ 438assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 439 self.slideshow.View.Previous() 440 441defnext(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 """ 446assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 447 self.slideshow.View.Next() 448 449deffirst(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 """ 454assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 455 self.slideshow.View.First() 456 457deflast(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 """ 462assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 463 self.slideshow.View.Last() 464 465defgoto(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 """ 470assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 471if slide <= self.slide_total(): 472 self.slideshow.View.GotoSlide(slide) 473else: 474 self.last() 475 self.next() 476 477defnormal(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 """ 485assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 486 self.slideshow.View.State =1 487broadcast_presentation(self) 488 489defblack(self): 490""" 491 Make the slideshow black if there is a slideshow running. 492 """ 493assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 494 self.slideshow.View.State =3 495broadcast_presentation(self) 496 497defwhite(self): 498""" 499 Make the slideshow white if there is a slideshow running. 500 """ 501assert self.slideshow is not None,"Slideshow is not running for {}".format(self.presentation.Name) 502 self.slideshow.View.State =4 503broadcast_presentation(self) 504 505defexport_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 514defexport(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. The slide is not exported if it has 518 a non-stale cached file. 519 """ 520 destination = config.prefs["Main"]["cache"] +"\\"+ self.presentation.Name +"\\"+str(slide) +"."+ config.prefs["Main"]["cache_format"].lower() 521 logger.debug("Exporting slide{} of {}".format(slide, self.presentation.Name)) 522 os.makedirs(os.path.dirname(destination), exist_ok=True) 523if not os.path.exists(destination)or(config.prefs.getint("Main","cache_timeout") >0and 524 time.time() - os.path.getmtime(destination) > config.prefs.getint("Main","cache_timeout")): 525if slide <= self.slide_total(): 526 attempts =0 527while attempts <3: 528try: 529 self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"]) 530break 531except: 532pass 533 attempts +=1 534elif slide == self.slide_total() +1: 535try: 536 shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''','rb'),open(destination,'wb')) 537exceptExceptionas exc: 538 logger.warning("Failed to copy black slide (number{} for presentation {}): {}".format(slide, self.presentation.Name, exc)) 539else: 540pass 541 542defexport_all(self): 543""" 544 Export all slides in the presentation 545 """ 546for i inrange(1, self.slide_total() +2): 547 self.export(i) 548 549defnull_action(*args): 550""" 551 Placeholder for disabled menu items in systray 552 """ 553pass 554 555defedit_config(*args): 556""" 557 Open the config file for editing in Notepad, and create the directory if not existing 558 """ 559 logger.debug("Opening config {}".format(pkg_base.CONFIG_PATH)) 560if not os.path.exists(pkg_base.CONFIG_DIR): 561try: 562 os.makedirs(pkg_base.CONFIG_DIR) 563 logger.info("Made directory {}".format(pkg_base.CONFIG_DIR)) 564exceptExceptionas exc: 565 logger.warning("Failed to create directory {} for config file".format( 566 pkg_base.CONFIG_DIR)) 567 icon.notify("Create {} manually".format((pkg_base.CONFIG_DIR[:40] +'...') 568iflen(pkg_base.CONFIG_DIR) >40else pkg_base.CONFIG_DIR), 569"Failed to create config directory") 570try: 571 os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH)) 572exceptExceptionas exc: 573 logger.warning("Failed to edit config{}: {}".format(pkg_base.CONFIG_PATH, exc)) 574 icon.notify("Edit {} manually".format((pkg_base.CONFIG_PATH[:40] +'...') 575iflen(pkg_base.CONFIG_PATH) >40else pkg_base.CONFIG_PATH), 576"Failed to open config") 577 578defrefresh(): 579""" 580 Clear COM events and update interface elements at an interval defined in "refresh" in the config 581 TODO: fix "argument of type 'com_error' is not iterable" 582 """ 583whilegetattr(refresh_daemon,"do_run",True): 584try: 585 pythoncom.PumpWaitingMessages() 586# TODO: don't regenerate entire menu on each refresh, use pystray.Icon.update_menu() 587 icon.menu = (pystray.MenuItem("Status: "+"dis"*(len(ppt_presentations) ==0) +"connected", 588lambda:null_action(), enabled=False), 589 pystray.MenuItem("Stop",lambda:exit(icon)), 590 pystray.MenuItem("Edit config",lambda:edit_config())) 591manage_protected_view(ppt_application) 592 time.sleep(float(config.prefs["Main"]["refresh"])) 593exceptExceptionas exc: 594# Deal with any exceptions, such as RPC server restarting, by reconnecting to application 595# (if this fails again, that's okay because we'll keep trying until it works) 596 logger.error("Error whilst refreshing state: {}".format(exc)) 597 app =get_application() 598 599 600defget_application(): 601""" 602 Create an Application object representing the PowerPoint application installed on the machine. 603 This should succeed regardless of whether PowerPoint is running, as long as PowerPoint is 604 installed. 605 Returns the Application object if successful, otherwise returns None. 606 """ 607try: 608return win32com.client.Dispatch('PowerPoint.Application') 609except pywintypes.com_error: 610# PowerPoint is probably not installed, or other COM failure 611return None 612 613 614defmanage_protected_view(app): 615""" 616 Attempt to unlock any presentations that have been opened in protected view. These cannot be 617 controlled by the program whilst they are in protected view, so we attempt to disable protected 618 view, or show a notification if this doesn't work for some reason. 619 """ 620try: 621if app.ProtectedViewWindows.Count >0: 622 logger.debug("Found open presentation(s) but at least one is in protected view") 623for i inrange(1, app.ProtectedViewWindows.Count +1):# +1 to account for indexing from 1 624 pres_name = app.ProtectedViewWindows(i).Presentation.Name 625if pres_name in disable_protected_attempted: 626continue 627if config.prefs.getboolean("Main","disable_protected"): 628try: 629 app.ProtectedViewWindows(i).Edit() 630 logger.info("Enabled editing for {}".format(pres_name)) 631exceptExceptionas exc: 632 icon.notify("Failed to disable protected view on\"{}\"".format((pres_name[:22] +'...') 633iflen(pres_name) >25else pres_name),"Disable protected view in PowerPoint") 634 logger.warning("Failed to disable protected view{} for editing - do this manually: {}".format(pres_name, exc)) 635 disable_protected_attempted.add(pres_name) 636else: 637 icon.notify("Cannot control\"{}\"in protected view".format((pres_name[:22] +'...') 638iflen(pres_name) >25else pres_name),"Disable protected view in PowerPoint") 639 logger.warning("Cannot control {} in protected view, and automatic disabling of protected view is turned off".format(pres_name)) 640 disable_protected_attempted.add(pres_name) 641exceptExceptionas exc: 642iftype(exc) == pywintypes.com_error and"application is busy"in exc: 643# PowerPoint needs some time to finish loading file if it has just been opened, 644# otherwise we get "The message filter indicated that the application is busy". Here, 645# we deal with this by gracefully ignoring any protected view windows until the next 646# refresh cycle, when PowerPoint is hopefully finished loading (if the refresh interval 647# is sufficiently long). 648 logger.debug("COM interface not taking requests right now - will try again on next refresh") 649return 650# Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients" 651# at this point. 652 logger.warning("{} whilst dealing with protected view windows: {}".format(type(exc), exc)) 653 app =get_application() 654 655 656 657defconnect_ppt(): 658""" 659 Connect to the PowerPoint COM interface and perform initial enumeration of open files 660 ("presentations"). Files that are subsequently opened are dealt with using COM events (see the 661 ApplicationEvents class above). Therefore, once we are finished setting things up, we just 662 call refresh() as a daemon in order to keep clients up to date. 663 """ 664 logger.debug("Searching for a PowerPoint slideshow...") 665global ppt_application 666global ppt_presentations 667 668# Initialise PowerPoint application 669 ppt_application =get_application() 670if ppt_application is None: 671# Couldn't find PowerPoint application 672 icon.notify("Couldn't find PowerPoint application","Error starting {}".format(PKG_NAME)) 673 logger.error("Couldn't find PowerPoint application - check that PowerPoint is installed and COM is working") 674 sys.exit() 675 676# Continue because we can connect to PowerPoint 677 logger.debug("Found PowerPoint application") 678 679# Dispatch events 680 win32com.client.WithEvents(ppt_application, ApplicationEvents) 681 logger.debug("Dispatched events") 682 683# Deal with windows in protected view 684manage_protected_view(ppt_application) 685 686# Initial enumeration of open presentations 687 logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations))) 688for n inrange(1,len(ppt_application.Presentations)+1):# PowerPoint's slide indexing starts at 1.. why!?!?!? 689 pres =Presentation(ppt_application, n) 690 icon.notify("Connected to {}".format(pres.presentation.Name), PKG_NAME) 691 logger.debug("Found presentation{} with index {}".format(pres.presentation.Name, n)) 692 ppt_presentations[pres.presentation.Name] = pres 693 refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh) 694 refresh_daemon.setDaemon(True) 695 logger.debug("Handing over to refresh daemon - goodbye...") 696iflen(ppt_presentations) ==0: 697# Provide some confirmation that the program has started if we haven't sent any 698# connection notifications yet 699 icon.notify("Started server", PKG_NAME) 700 refresh_daemon.start() 701 702 703defstart_server(_=None): 704""" 705 Start HTTP and WS servers, then connect to PPT instance with connect_ppt() which will then 706 set off the refresh daemon. 707 """ 708setup_http() 709setup_ws() 710connect_ppt() 711 712 713defexit(icon): 714""" 715 Clean up and exit when user clicks "Stop" from systray menu 716 """ 717 logger.debug("User requested shutdown") 718 icon.visible =False 719 icon.stop() 720 721 722defstart_interface(): 723""" 724 Main entrypoint for the program. Loads config and logging, starts systray icon, and calls 725 start_server() to start the backend. 726 """ 727global icon 728global logger 729# Load config 730 config.prefs = config.loadconf(pkg_base.CONFIG_PATH) 731 732# Set up logging 733if config.prefs["Main"]["logging"] =="debug": 734 log_level = logging.DEBUG 735elif config.prefs["Main"]["logging"] =="info": 736 log_level = logging.INFO 737else: 738 log_level = logging.WARNING 739 740 log_formatter = logging.Formatter("%(asctime)s[%(threadName)-12.12s] [%(levelname)-7.7s]%(message)s") 741 logger = logging.getLogger("ppt-control") 742 logger.setLevel(log_level) 743 logger.propagate =False 744 745 console_handler = logging.StreamHandler() 746 console_handler.setFormatter(log_formatter) 747 console_handler.setLevel(log_level) 748 logger.addHandler(console_handler) 749 750if not os.path.exists(pkg_base.LOG_DIR): 751try: 752 os.makedirs(pkg_base.LOG_DIR) 753 logger.info("Made directory {}".format(pkg_base.LOG_DIR)) 754exceptExceptionas exc: 755 logger.warning("Failed to create directory {} for log".format( 756 pkg_base.LOG_DIR)) 757 icon.notify("Create {} manually".format((pkg_base.LOG_DIR[:40] +'...') 758iflen(pkg_base.LOG_DIR) >40else pkg_base.LOG_DIR), 759"Failed to create log directory") 760 file_handler = logging.FileHandler(pkg_base.LOG_PATH) 761 file_handler.setFormatter(log_formatter) 762 file_handler.setLevel(log_level) 763 logger.addHandler(file_handler) 764 765#logging.getLogger("asyncio").setLevel(logging.ERROR) 766#logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR) 767 logging.getLogger("websockets.server").setLevel(logging.ERROR) 768#logging.getLogger("websockets.protocol").setLevel(logging.ERROR) 769 770 771 logger.debug("Finished setting up config and logging") 772 773# Start systray icon and server 774 logger.debug("Starting system tray icon") 775 icon =MyIcon(PKG_NAME) 776 icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''') 777 icon.title ="{} {}".format(PKG_NAME, PKG_VERSION) 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__": 785start_interface()