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