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. 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) 522if not os.path.exists(destination)or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main","cache_timeout"): 523if slide <= self.slide_total(): 524 attempts =0 525while attempts <3: 526try: 527 self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"]) 528break 529except: 530pass 531 attempts +=1 532elif slide == self.slide_total() +1: 533try: 534 shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''','rb'),open(destination,'wb')) 535exceptExceptionas exc: 536 logger.warning("Failed to copy black slide (number{} for presentation {}): {}".format(slide, self.presentation.Name, exc)) 537else: 538pass 539 540defexport_all(self): 541""" 542 Export all slides in the presentation 543 """ 544for i inrange(1, self.slide_total() +2): 545 self.export(i) 546 547defnull_action(*args): 548""" 549 Placeholder for disabled menu items in systray 550 """ 551pass 552 553defedit_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)) 558if not os.path.exists(pkg_base.CONFIG_DIR): 559try: 560 os.makedirs(pkg_base.CONFIG_DIR) 561 logger.info("Made directory {}".format(pkg_base.CONFIG_DIR)) 562exceptExceptionas 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] +'...') 566iflen(pkg_base.CONFIG_DIR) >40else pkg_base.CONFIG_DIR), 567"Failed to create config directory") 568try: 569 os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH)) 570exceptExceptionas 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] +'...') 573iflen(pkg_base.CONFIG_PATH) >40else pkg_base.CONFIG_PATH), 574"Failed to open config") 575 576defrefresh(): 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 """ 581whilegetattr(refresh_daemon,"do_run",True): 582try: 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", 586lambda:null_action(), enabled=False), 587 pystray.MenuItem("Stop",lambda:exit(icon)), 588 pystray.MenuItem("Edit config",lambda:edit_config())) 589manage_protected_view(ppt_application) 590 time.sleep(float(config.prefs["Main"]["refresh"])) 591exceptExceptionas 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 598defget_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 """ 605try: 606return win32com.client.Dispatch('PowerPoint.Application') 607except pywintypes.com_error: 608# PowerPoint is probably not installed, or other COM failure 609return None 610 611 612defmanage_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 """ 618try: 619if app.ProtectedViewWindows.Count >0: 620 logger.debug("Found open presentation(s) but at least one is in protected view") 621for i inrange(1, app.ProtectedViewWindows.Count +1):# +1 to account for indexing from 1 622 pres_name = app.ProtectedViewWindows(i).Presentation.Name 623if pres_name in disable_protected_attempted: 624continue 625if config.prefs.getboolean("Main","disable_protected"): 626try: 627 app.ProtectedViewWindows(i).Edit() 628 logger.info("Enabled editing for {}".format(pres_name)) 629exceptExceptionas exc: 630 icon.notify("Failed to disable protected view on\"{}\"".format((pres_name[:22] +'...') 631iflen(pres_name) >25else 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) 634else: 635 icon.notify("Cannot control\"{}\"in protected view".format((pres_name[:22] +'...') 636iflen(pres_name) >25else 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) 639exceptExceptionas exc: 640iftype(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") 647return 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 655defconnect_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...") 663global ppt_application 664global ppt_presentations 665 666# Initialise PowerPoint application 667 ppt_application =get_application() 668if 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 682manage_protected_view(ppt_application) 683 684# Initial enumeration of open presentations 685 logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations))) 686for n inrange(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...") 694iflen(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 701defstart_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 """ 706setup_http() 707setup_ws() 708connect_ppt() 709 710 711defexit(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 720defstart_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 """ 725global icon 726global logger 727# Load config 728 config.prefs = config.loadconf(pkg_base.CONFIG_PATH) 729 730# Set up logging 731if config.prefs["Main"]["logging"] =="debug": 732 log_level = logging.DEBUG 733elif config.prefs["Main"]["logging"] =="info": 734 log_level = logging.CRITICAL 735else: 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 749if not os.path.exists(pkg_base.LOG_DIR): 750try: 751 os.makedirs(pkg_base.LOG_DIR) 752 logger.info("Made directory {}".format(pkg_base.LOG_DIR)) 753exceptExceptionas 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] +'...') 757iflen(pkg_base.LOG_DIR) >40else 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__": 785start_interface()