ppt_control / ppt_control.pyon commit bump version (e5c6ba6)
   1import sys
   2sys.coinit_flags= 0
   3import win32com.client
   4import pywintypes
   5import os
   6import shutil
   7import socketserver
   8import threading
   9import asyncio
  10import websockets
  11import logging, logging.handlers
  12import json
  13import urllib
  14import posixpath
  15import time
  16import pythoncom
  17import pystray
  18from PIL import Image
  19from pystray._util import win32
  20from copy import copy
  21
  22import ppt_control.__init__ as pkg_base
  23import ppt_control.http_server_39 as http_server    # 3.9 version of the HTTP server (details in module)
  24import ppt_control.config as config
  25
  26logging.basicConfig()
  27
  28http_daemon = None
  29my_http_server = None
  30ws_daemon = None
  31users = set()
  32logger = None
  33refresh_daemon = None
  34icon = None
  35ppt_application = None
  36ppt_presentations = {}
  37disable_protected_attempted = set()
  38
  39PKG_NAME = pkg_base.__name__
  40PKG_VERSION = pkg_base.__version__
  41DELAY_CLOSE = 0.2
  42DELAY_PROTECTED = 0.5
  43DELAY_FINAL = 0.1
  44
  45class MyIcon(pystray.Icon):
  46    """
  47    Custom pystray.Icon class which displays menu when left-clicking on icon, as well as the 
  48    default right-click behaviour.
  49    """
  50    def _on_notify(self, wparam, lparam):
  51        """Handles ``WM_NOTIFY``.
  52        If this is a left button click, this icon will be activated. If a menu
  53        is registered and this is a right button click, the popup menu will be
  54        displayed.
  55        """
  56        if lparam == win32.WM_LBUTTONUP or (
  57                self._menu_handle and lparam == win32.WM_RBUTTONUP):
  58            super()._on_notify(wparam, win32.WM_RBUTTONUP)
  59
  60class Handler(http_server.SimpleHTTPRequestHandler):
  61    """
  62    Custom handler to translate /cache* urls to the cache directory (set in the config)
  63    """
  64    def __init__(self, *args, **kwargs):
  65        super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''')
  66        
  67    def log_request(self, code='-', size='-'):
  68        return
  69
  70        
  71    def translate_path(self, path):
  72        """Translate a /-separated PATH to the local filename syntax.
  73
  74        Components that mean special things to the local file system
  75        (e.g. drive or directory names) are ignored.  (XXX They should
  76        probably be diagnosed.)
  77
  78        """
  79        # abandon query parameters
  80        path = path.split('?',1)[0]
  81        path = path.split('#',1)[0]
  82        # Don't forget explicit trailing slash when normalizing. Issue17324
  83        trailing_slash = path.rstrip().endswith('/')
  84        try:
  85            path = urllib.parse.unquote(path, errors='surrogatepass')
  86        except UnicodeDecodeError:
  87            path = urllib.parse.unquote(path)
  88        path = posixpath.normpath(path)
  89        words = path.split('/')
  90        words = list(filter(None, words))
  91        if len(words) > 0 and words[0] == "cache":
  92            if words[1] in ppt_presentations:
  93                path = config.prefs["Main"]["cache"]
  94            else:
  95                path = "black.jpg"
  96                logger.warning("Request for cached file {} for non-existent presentation".format(path))
  97            words.pop(0)
  98        else:
  99            path = self.directory
 100        for word in words:
 101            if os.path.dirname(word) or word in (os.curdir, os.pardir):
 102                # Ignore components that are not a simple file/directory name
 103                continue
 104            path = os.path.join(path, word)
 105        if trailing_slash:
 106            path += '/'
 107        return path
 108
 109async def ws_handler(websocket, path):
 110    """
 111    Handle a WebSockets connection
 112    """
 113    logger.debug("Handling WebSocket connection")
 114    recv_task = asyncio.ensure_future(ws_receive(websocket, path))
 115    send_task = asyncio.ensure_future(ws_send(websocket, path))
 116    done, pending = await asyncio.wait(
 117        [recv_task, send_task],
 118        return_when=asyncio.FIRST_COMPLETED,
 119    )
 120    for task in pending:
 121        task.cancel()
 122
 123async def ws_receive(websocket, path):
 124    """
 125    Process data received on the WebSockets connection
 126    """
 127    users.add(websocket)
 128    try:
 129        # Send initial state to clients on load
 130        for pres in ppt_presentations:
 131            broadcast_presentation(ppt_presentations[pres])
 132        async for message in websocket:
 133            logger.debug("Received websocket message: " + str(message))
 134            data = json.loads(message)
 135            if data["presentation"]:
 136                pres = ppt_presentations[data["presentation"]]
 137            else:
 138                # Control last-initialised presentation if none specified (e.g. if using OBS script
 139                # which doesn't have any visual feedback and hence no method to choose a
 140                # presentation). This relies on any operations on the ppt_presentations dictionary
 141                # being stable so that the order does not change. So far no problems have been
 142                # detected with this, but it is not an ideal method.
 143                pres = ppt_presentations[list(ppt_presentations.keys())[-1]]
 144            if data["action"] == "prev":
 145                pres.prev()
 146            elif data["action"] == "next":
 147                pres.next()
 148                # Advancing to the black screen before the slideshow ends doesn't trigger 
 149                # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and
 150                # broadcast the new state if necessary. A delay is required since the event is 
 151                # triggered before the slideshow is actually closed, and we don't want to attempt
 152                # to check the current slide of a slideshow that isn't running.
 153                time.sleep(DELAY_FINAL)
 154                if (pres.get_slideshow() is not None and 
 155                        pres.slide_current() == pres.slide_total() + 1):
 156                    logger.debug("Advanced to black slide before end")
 157                    broadcast_presentation(pres)
 158            elif data["action"] == "first":
 159                pres.first()
 160            elif data["action"] == "last":
 161                pres.last()
 162            elif data["action"] == "black":
 163                if pres.state() == 3 or (
 164                    config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 4):
 165                    pres.normal()
 166                else:
 167                    pres.black()
 168            elif data["action"] == "white":
 169                if pres.state() == 4  or (
 170                    config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 3):
 171                    pres.normal()
 172                else:
 173                    pres.white()
 174            elif data["action"] == "goto":
 175                pres.goto(int(data["value"]))
 176                # Advancing to the black screen before the slideshow ends doesn't trigger 
 177                # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and
 178                # broadcast the new state if necessary. A delay is required since the event is 
 179                # triggered before the slideshow is actually closed, and we don't want to attempt
 180                # to check the current slide of a slideshow that isn't running.
 181                time.sleep(DELAY_FINAL)
 182                if (pres.get_slideshow() is not None and 
 183                        pres.slide_current() == pres.slide_total() + 1):
 184                    logger.debug("Jumped to black slide before end")
 185                    broadcast_presentation(pres)
 186            elif data["action"] == "start":
 187                pres.start_slideshow()
 188            elif data["action"] == "stop":
 189                pres.stop_slideshow()
 190            else:
 191                logger.error("Received unnsupported event: {}", data)
 192    finally:
 193        users.remove(websocket)
 194
 195async def ws_send(websocket, path):
 196    """
 197    Broadcast data to all WebSockets clients
 198    """
 199    while True:
 200        message = await ws_queue.get()
 201        await asyncio.wait([user.send(message) for user in users])
 202
 203
 204def run_http():
 205    """
 206    Start the HTTP server
 207    """
 208    global my_http_server
 209    my_http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)
 210    my_http_server.serve_forever()
 211
 212
 213def run_ws():
 214    """
 215    Set up threading/async for WebSockets server
 216    """
 217    # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084
 218    # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/
 219    pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)
 220    asyncio.set_event_loop(asyncio.new_event_loop())
 221    global ws_queue
 222    ws_queue = asyncio.Queue()
 223    global loop
 224    loop = asyncio.get_event_loop()
 225    start_server = websockets.serve(ws_handler, config.prefs["WebSocket"]["interface"], config.prefs.getint("WebSocket", "port"), ping_interval=None)
 226    asyncio.get_event_loop().run_until_complete(start_server)
 227    asyncio.get_event_loop().run_forever()
 228
 229def setup_http():
 230    """
 231    Set up threading for HTTP server
 232    """
 233    http_daemon = threading.Thread(name="http_daemon", target=run_http)
 234    http_daemon.setDaemon(True)
 235    http_daemon.start()
 236    logger.info("Started HTTP server")
 237
 238def setup_ws():
 239    """
 240    Set up threading for WebSockets server
 241    """
 242    global ws_daemon
 243    ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)
 244    ws_daemon.setDaemon(True)
 245    ws_daemon.start()
 246    logger.info("Started websocket server")
 247    
 248
 249def broadcast_presentation(pres):
 250    """
 251    Broadcast the state of a single presentation to all connected clients. Also ensures the current
 252    slide and the two upcoming slides are exported and cached.
 253    """
 254    name = pres.presentation.Name
 255    pres_open = name in ppt_presentations
 256    slideshow = pres.slideshow is not None
 257    visible = pres.state()
 258    slide_current = pres.slide_current()
 259    slide_total = pres.slide_total()
 260
 261    pres.export_current_next()
 262    
 263    if users:   # asyncio.wait doesn't accept an empty list
 264        state = {"name": name, "pres_open": pres_open, "slideshow": slideshow, "visible": visible,
 265                    "slide_current": slide_current, "slide_total": slide_total}
 266        loop.call_soon_threadsafe(ws_queue.put_nowait, json.dumps({"type": "state", **state}))
 267
 268class ApplicationEvents:
 269    """
 270    Events assigned to the root application.
 271    Ref: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application#events
 272    """
 273    def OnSlideShowNextSlide(self, window, *args):
 274        """
 275        Triggered when the current slide number of any slideshow is incremented, locally or through
 276        ppt_control.
 277        """
 278        logger.debug("Slide advanced for {}".format(window.Presentation.Name))
 279        broadcast_presentation(ppt_presentations[window.Presentation.Name])
 280
 281    def OnSlideShowPrevSlide(self, window, *args):
 282        """
 283        Triggered when the current slide number of any slideshow is decremented, locally or through
 284        ppt_control.
 285        """
 286        logger.debug("Slide decremented for {}".format(window.Presentation.Name))
 287        broadcast_presentation(ppt_presentations[window.Presentation.Name])
 288    
 289    def OnAfterPresentationOpen(self, presentation, *args):
 290        """
 291        Triggered when an existing presentation is opened. This adds the newly opened presentation
 292        to the list of open presentations.
 293        """
 294        logger.debug("Presentation {} opened - adding to list".format(presentation.Name))
 295        global ppt_presentations
 296        ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)
 297        broadcast_presentation(ppt_presentations[presentation.Name])
 298        disable_protected_attempted.discard(presentation.Name)
 299        icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)
 300
 301    def OnAfterNewPresentation(self, presentation, *args):
 302        """
 303        Triggered when a new presentation is opened. This adds the new presentation to the list
 304        of open presentations.
 305        """
 306        logger.debug("Presentation {} opened (blank) - adding to list".format(presentation.Name))
 307        global ppt_presentations
 308        ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)
 309        broadcast_presentation(ppt_presentations[presentation.Name])
 310        icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)
 311
 312    def OnPresentationClose(self, presentation, *args):
 313        """
 314        Triggered when a presentation is closed. This removes the presentation from the list of
 315        open presentations. A delay is included to make sure the presentation is 
 316        actually closed, since the event is called simultaneously as the presentation is removed
 317        from PowerPoint's internal structure. Ref:
 318        https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application.presentationclose
 319        """
 320        logger.debug("Presentation {} closed - removing from list".format(presentation.Name))
 321        global ppt_presentations
 322        time.sleep(DELAY_CLOSE)
 323        broadcast_presentation(ppt_presentations.pop(presentation.Name))
 324        icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)
 325
 326    def OnSlideShowBegin(self, window, *args):
 327        """
 328        Triggered when a slideshow is started. This initialises the Slideshow object in the 
 329        appropriate Presentation object.
 330        """
 331        logger.debug("Slideshow started for {}".format(window.Presentation.Name))
 332        global ppt_presentations
 333        ppt_presentations[window.Presentation.Name].slideshow = window
 334        broadcast_presentation(ppt_presentations[window.Presentation.Name])
 335    
 336    def OnSlideShowEnd(self, presentation, *args):
 337        """
 338        Triggered when a slideshow is ended. This deinitialises the Slideshow object in the 
 339        appropriate Presentation object.
 340        """
 341        logger.debug("Slideshow ended for {}".format(presentation.Name))
 342        global ppt_presentations
 343        ppt_presentations[presentation.Name].slideshow = None
 344        broadcast_presentation(ppt_presentations[presentation.Name])
 345
 346
 347class Presentation:
 348    """
 349    Class representing an instance of PowerPoint with a file open (so-called "presentation"
 350    in PowerPoint terms). Mostly just a wrapper for PowerPoint's `Presentation` object.
 351    """
 352    def __init__(self, application, pres_index=None, pres_obj=None):
 353        """
 354        Initialise a Presentation object.
 355            application     The PowerPoint application which the presentation is being run within
 356            pres_index      PowerPoint's internal presentation index (NOTE this is indexed from 1)
 357        """
 358        if pres_index == None and pres_obj == None:
 359            raise ValueError("Cannot initialise a presentation without a presentation ID or object")
 360        assert len(application.Presentations) > 0, "Cannot initialise presentation from application with no presentations"
 361
 362        self.__application = application
 363        if pres_obj is not None:
 364            self.presentation = pres_obj
 365        else:
 366            self.presentation = application.Presentations(pres_index)
 367        self.slideshow = self.get_slideshow()
 368
 369
 370    def get_slideshow(self):
 371        """
 372        Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow.
 373        """
 374        try:
 375            return self.presentation.SlideShowWindow
 376        except pywintypes.com_error as exc:
 377            logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc))
 378            return None
 379
 380    def start_slideshow(self):
 381        """
 382        Start the slideshow. Updating the state of this object is managed by the OnSlideshowBegin 
 383        event of the applicable Application.
 384        """
 385        if self.get_slideshow() is None:
 386            self.presentation.SlideShowSettings.Run()
 387        else:
 388            logger.warning("Cannot start slideshow that is already running (presentation {})".format(
 389                self.presentation.Name))
 390
 391    def stop_slideshow(self):
 392        """
 393        Stop the slideshow. Updating the state of this object is managed by the OnSlideshowEnd
 394        event of the applicable Application.
 395        """
 396        if self.get_slideshow() is not None:
 397            self.presentation.SlideShowWindow.View.Exit()
 398        else:
 399            logger.warning("Cannot stop slideshow that is not running (presentation {})".format(
 400                self.presentation.Name))
 401
 402    def state(self):
 403        """
 404        Returns the visibility state of the slideshow:
 405        1: running
 406        2: paused
 407        3: black
 408        4: white
 409        5: done
 410        Source: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate
 411        """
 412        if self.slideshow is not None:
 413            return self.slideshow.View.State
 414        else:
 415            return 0
 416
 417    def slide_current(self):
 418        """
 419        Returns the current slide number of the slideshow, or 0 if no slideshow is running.
 420        """
 421        if self.slideshow is not None:
 422            return self.slideshow.View.CurrentShowPosition
 423        else:
 424            return 0
 425
 426    def slide_total(self):
 427        """
 428        Returns the total number of slides in the presentation, regardless of whether a slideshow
 429        is running.
 430        """
 431        return self.presentation.Slides.Count
 432
 433    def prev(self):
 434        """
 435        Go to the previous slide if there is a slideshow running. Notifying clients of the new state
 436        is managed by the ApplicationEvent.
 437        """
 438        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 439        self.slideshow.View.Previous()
 440
 441    def next(self):
 442        """
 443        Go to the previous slide if there is a slideshow running. Notifying clients of the new state
 444        is managed by the ApplicationEvent.
 445        """
 446        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 447        self.slideshow.View.Next()
 448
 449    def first(self):
 450        """
 451        Go to the first slide if there is a slideshow running. Notifying clients of the new state
 452        is managed by the ApplicationEvent.
 453        """
 454        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 455        self.slideshow.View.First()
 456                
 457    def last(self):
 458        """
 459        Go to the last slide if there is a slideshow running. Notifying clients of the new state
 460        is managed by the ApplicationEvent.
 461        """
 462        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 463        self.slideshow.View.Last()
 464
 465    def goto(self, slide):
 466        """
 467        Go to a numbered slide if there is a slideshow running. Notifying clients of the new state
 468        is managed by the ApplicationEvent.
 469        """
 470        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 471        if slide <= self.slide_total():
 472            self.slideshow.View.GotoSlide(slide)
 473        else:
 474            self.last()
 475            self.next()
 476
 477    def normal(self):
 478        """
 479        Make the slideshow visible if there is a slideshow running. Note this puts the slideshow into 
 480        "running" state rather than the normal "paused" to ensure animations work correctly and the 
 481        slide is actually visible after changing the state. The state is normally returned to 
 482        "paused" automatically by PPT when advancing to the following slide. State enumeration ref: 
 483        https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate
 484        """
 485        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 486        self.slideshow.View.State = 1
 487        broadcast_presentation(self)
 488
 489    def black(self):
 490        """
 491        Make the slideshow black if there is a slideshow running. 
 492        """
 493        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 494        self.slideshow.View.State = 3
 495        broadcast_presentation(self)
 496
 497    def white(self):
 498        """
 499        Make the slideshow white if there is a slideshow running. 
 500        """
 501        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
 502        self.slideshow.View.State = 4
 503        broadcast_presentation(self)
 504
 505    def export_current_next(self):
 506        """
 507        Export the current slide, the next slide, and the one after (ensures enough images are 
 508        always cached)
 509        """
 510        self.export(self.slide_current())
 511        self.export(self.slide_current() + 1)
 512        self.export(self.slide_current() + 2)
 513
 514    def export(self, slide):
 515        """
 516        Export a relatively low-resolution image of a slide using PowerPoint's built-in export 
 517        function. The cache destination is set in the config. 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)
 523        if not os.path.exists(destination) or (config.prefs.getint("Main", "cache_timeout") > 0 and 
 524                time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout")):
 525            if slide <= self.slide_total():
 526                attempts = 0
 527                while attempts < 3:
 528                    try:
 529                        self.presentation.Slides(slide).Export(destination, config.prefs["Main"]["cache_format"])
 530                        break
 531                    except:
 532                        pass
 533                    attempts += 1
 534            elif slide == self.slide_total() + 1:
 535                try:
 536                    shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))
 537                except Exception as exc:
 538                    logger.warning("Failed to copy black slide (number {} for presentation {}): {}".format(slide, self.presentation.Name, exc))
 539            else:
 540                pass
 541
 542    def export_all(self):
 543        """
 544        Export all slides in the presentation
 545        """
 546        for i in range(1, self.slide_total() + 2):
 547            self.export(i)
 548
 549def null_action(*args):
 550    """
 551    Placeholder for disabled menu items in systray
 552    """
 553    pass
 554
 555def edit_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))
 560    if not os.path.exists(pkg_base.CONFIG_DIR):
 561        try:
 562            os.makedirs(pkg_base.CONFIG_DIR)
 563            logger.info("Made directory {}".format(pkg_base.CONFIG_DIR))
 564        except Exception as 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] + '...') 
 568                        if len(pkg_base.CONFIG_DIR) > 40 else pkg_base.CONFIG_DIR),
 569                        "Failed to create config directory")
 570    try:
 571        os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH))
 572    except Exception as 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] + '...') 
 575                        if len(pkg_base.CONFIG_PATH) > 40 else pkg_base.CONFIG_PATH),
 576                        "Failed to open config")
 577                
 578def refresh():
 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    """
 583    while getattr(refresh_daemon, "do_run", True):
 584        try:
 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",
 588                lambda: null_action(), enabled=False),
 589                pystray.MenuItem("Stop", lambda: exit(icon)),
 590                pystray.MenuItem("Edit config", lambda: edit_config()))
 591            manage_protected_view(ppt_application)
 592            time.sleep(float(config.prefs["Main"]["refresh"]))
 593        except Exception as 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
 600def get_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    """
 607    try:
 608        return win32com.client.Dispatch('PowerPoint.Application')
 609    except pywintypes.com_error:
 610        # PowerPoint is probably not installed, or other COM failure
 611        return None
 612        
 613
 614def manage_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    """
 620    try:
 621        if app.ProtectedViewWindows.Count > 0:
 622            logger.debug("Found open presentation(s) but at least one is in protected view")
 623            for i in range(1, app.ProtectedViewWindows.Count + 1):  # +1 to account for indexing from 1
 624                pres_name = app.ProtectedViewWindows(i).Presentation.Name
 625                if pres_name in disable_protected_attempted:
 626                    continue
 627                if config.prefs.getboolean("Main", "disable_protected"):
 628                    try:
 629                        app.ProtectedViewWindows(i).Edit()
 630                        logger.info("Enabled editing for {}".format(pres_name))
 631                    except Exception as exc:
 632                        icon.notify("Failed to disable protected view on \"{}\"".format((pres_name[:22] + '...') 
 633                        if len(pres_name) > 25 else 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)
 636                else:
 637                    icon.notify("Cannot control \"{}\" in protected view".format((pres_name[:22] + '...') 
 638                        if len(pres_name) > 25 else 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)
 641    except Exception as exc:
 642        if type(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")
 649            return
 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
 657def connect_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...")
 665    global ppt_application
 666    global ppt_presentations
 667
 668    # Initialise PowerPoint application
 669    ppt_application = get_application()
 670    if 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
 684    manage_protected_view(ppt_application)
 685
 686    # Initial enumeration of open presentations
 687    logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations)))
 688    for n in range(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...")
 696    if len(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
 703def start_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    """
 708    setup_http()
 709    setup_ws()
 710    connect_ppt()
 711        
 712        
 713def exit(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
 722def start_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    """
 727    global icon
 728    global logger
 729    # Load config
 730    config.prefs = config.loadconf(pkg_base.CONFIG_PATH)
 731
 732    # Set up logging
 733    if config.prefs["Main"]["logging"] == "debug":
 734        log_level = logging.DEBUG
 735    elif config.prefs["Main"]["logging"] == "info":
 736        log_level = logging.INFO
 737    else:
 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
 750    if not os.path.exists(pkg_base.LOG_DIR):
 751        try:
 752            os.makedirs(pkg_base.LOG_DIR)
 753            logger.info("Made directory {}".format(pkg_base.LOG_DIR))
 754        except Exception as 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] + '...') 
 758                        if len(pkg_base.LOG_DIR) > 40 else 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__":
 785    start_interface()