update todos in readme
[ppt-control.git] / ppt_control / ppt_control.py
index 1f4cfde85895063ba2d14b65f3b838fba9fa0760..e5a38c145c88338418bbc1d296ef77184f3d03c1 100755 (executable)
@@ -4,7 +4,6 @@ import win32com.client
 import pywintypes\r
 import os\r
 import shutil\r
-#import http.server as server\r
 import socketserver\r
 import threading\r
 import asyncio\r
@@ -16,49 +15,52 @@ import posixpath
 import time\r
 import pythoncom\r
 import pystray\r
-import tkinter as tk\r
-from tkinter import ttk\r
 from PIL import Image\r
+from pystray._util import win32\r
 from copy import copy\r
 \r
-import ppt_control.http_server_39 as server\r
+import ppt_control.__init__ as pkg_base\r
+import ppt_control.http_server_39 as http_server    # 3.9 version of the HTTP server (details in module)\r
 import ppt_control.config as config\r
 \r
 logging.basicConfig()\r
 \r
-global http_daemon\r
-global ws_daemon\r
-global STATE\r
-global STATE_DEFAULT\r
-global current_slideshow\r
-global interface_root\r
-global interface_thread\r
-global logger\r
-global refresh_daemon\r
-global status_label\r
-global http_label\r
-global ws_label\r
-global reset_ppt_button\r
-global http_server\r
-global icon\r
-scheduler = None\r
-current_slideshow = None\r
-interface_root = None\r
-interface_thread = None\r
-CONFIG_FILE = r'''..\ppt-control.ini'''\r
-LOGFILE = r'''..\ppt-control.log'''\r
+http_daemon = None\r
+my_http_server = None\r
+ws_daemon = None\r
+users = set()\r
 logger = None\r
 refresh_daemon = None\r
-status_label = None\r
-http_label = None\r
-ws_label = None\r
-ws_daemon = None\r
-http_server = None\r
-reset_ppt_button = None\r
 icon = None\r
-\r
-\r
-class Handler(server.SimpleHTTPRequestHandler):\r
+ppt_application = None\r
+ppt_presentations = {}\r
+disable_protected_attempted = set()\r
+\r
+PKG_NAME = pkg_base.__name__\r
+PKG_VERSION = pkg_base.__version__\r
+DELAY_CLOSE = 0.2\r
+DELAY_PROTECTED = 0.5\r
+DELAY_FINAL = 0.1\r
+\r
+class MyIcon(pystray.Icon):\r
+    """\r
+    Custom pystray.Icon class which displays menu when left-clicking on icon, as well as the \r
+    default right-click behaviour.\r
+    """\r
+    def _on_notify(self, wparam, lparam):\r
+        """Handles ``WM_NOTIFY``.\r
+        If this is a left button click, this icon will be activated. If a menu\r
+        is registered and this is a right button click, the popup menu will be\r
+        displayed.\r
+        """\r
+        if lparam == win32.WM_LBUTTONUP or (\r
+                self._menu_handle and lparam == win32.WM_RBUTTONUP):\r
+            super()._on_notify(wparam, win32.WM_RBUTTONUP)\r
+\r
+class Handler(http_server.SimpleHTTPRequestHandler):\r
+    """\r
+    Custom handler to translate /cache* urls to the cache directory (set in the config)\r
+    """\r
     def __init__(self, *args, **kwargs):\r
         super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''')\r
         \r
@@ -87,16 +89,11 @@ class Handler(server.SimpleHTTPRequestHandler):
         words = path.split('/')\r
         words = list(filter(None, words))\r
         if len(words) > 0 and words[0] == "cache":\r
-            black = 0\r
-            if current_slideshow:\r
-                try:\r
-                    path = config.prefs["Main"]["cache"] + "\\" + current_slideshow.name()\r
-                except Exception as e:\r
-                    path = "black.jpg"\r
-                    logger.warning("Failed to get current slideshow name: ", e)\r
+            if words[1] in ppt_presentations:\r
+                path = config.prefs["Main"]["cache"]\r
             else:\r
                 path = "black.jpg"\r
-                return path\r
+                logger.warning("Request for cached file {} for non-existent presentation".format(path))\r
             words.pop(0)\r
         else:\r
             path = self.directory\r
@@ -109,41 +106,10 @@ class Handler(server.SimpleHTTPRequestHandler):
             path += '/'\r
         return path\r
 \r
-\r
-def run_http():\r
-    global http_server\r
-    http_server = server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
-    http_server.serve_forever()\r
-\r
-STATE_DEFAULT = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""}\r
-STATE = copy(STATE_DEFAULT)\r
-USERS = set()\r
-\r
-\r
-def state_event():\r
-    return json.dumps({"type": "state", **STATE})\r
-\r
-\r
-def notify_state():\r
-    global STATE\r
-    if current_slideshow and STATE["connected"] == 1:\r
-        try:\r
-            STATE["current"] = current_slideshow.current_slide()\r
-            STATE["total"] = current_slideshow.total_slides()\r
-            STATE["visible"] = current_slideshow.visible()\r
-            STATE["name"] = current_slideshow.name()\r
-        except Exception as e:\r
-            logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))\r
-            current_slideshow.unload()\r
-    else:\r
-        STATE = copy(STATE_DEFAULT)\r
-    if USERS:  # asyncio.wait doesn't accept an empty list\r
-        message = state_event()\r
-        loop.call_soon_threadsafe(ws_queue.put_nowait, state_event())\r
-\r
-\r
-\r
 async def ws_handler(websocket, path):\r
+    """\r
+    Handle a WebSockets connection\r
+    """\r
     logger.debug("Handling WebSocket connection")\r
     recv_task = asyncio.ensure_future(ws_receive(websocket, path))\r
     send_task = asyncio.ensure_future(ws_send(websocket, path))\r
@@ -155,60 +121,99 @@ async def ws_handler(websocket, path):
         task.cancel()\r
 \r
 async def ws_receive(websocket, path):\r
-    logger.debug("Received websocket request")\r
-    USERS.add(websocket)\r
+    """\r
+    Process data received on the WebSockets connection\r
+    """\r
+    users.add(websocket)\r
     try:\r
         # Send initial state to clients on load\r
-        notify_state()\r
+        for pres in ppt_presentations:\r
+            broadcast_presentation(ppt_presentations[pres])\r
         async for message in websocket:\r
             logger.debug("Received websocket message: " + str(message))\r
             data = json.loads(message)\r
+            if data["presentation"]:\r
+                pres = ppt_presentations[data["presentation"]]\r
+            else:\r
+                # Control last-initialised presentation if none specified (e.g. if using OBS script\r
+                # which doesn't have any visual feedback and hence no method to choose a\r
+                # presentation). This relies on any operations on the ppt_presentations dictionary\r
+                # being stable so that the order does not change. So far no problems have been\r
+                # detected with this, but it is not an ideal method.\r
+                pres = ppt_presentations[list(ppt_presentations.keys())[-1]]\r
             if data["action"] == "prev":\r
-                if current_slideshow:\r
-                    current_slideshow.prev()\r
-                notify_state()\r
+                pres.prev()\r
             elif data["action"] == "next":\r
-                if current_slideshow:\r
-                    current_slideshow.next()\r
-                notify_state()\r
+                pres.next()\r
+                # Advancing to the black screen before the slideshow ends doesn't trigger \r
+                # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and\r
+                # broadcast the new state if necessary. A delay is required since the event is \r
+                # triggered before the slideshow is actually closed, and we don't want to attempt\r
+                # to check the current slide of a slideshow that isn't running.\r
+                time.sleep(DELAY_FINAL)\r
+                if (pres.get_slideshow() is not None and \r
+                        pres.slide_current() == pres.slide_total() + 1):\r
+                    logger.debug("Advanced to black slide before end")\r
+                    broadcast_presentation(pres)\r
             elif data["action"] == "first":\r
-                if current_slideshow:\r
-                    current_slideshow.first()\r
-                notify_state()\r
+                pres.first()\r
             elif data["action"] == "last":\r
-                if current_slideshow:\r
-                    current_slideshow.last()\r
-                notify_state()\r
+                pres.last()\r
             elif data["action"] == "black":\r
-                if current_slideshow:\r
-                    if current_slideshow.visible() == 3:\r
-                        current_slideshow.normal()\r
-                    else:\r
-                        current_slideshow.black()\r
-                notify_state()\r
+                if pres.state() == 3 or (\r
+                    config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 4):\r
+                    pres.normal()\r
+                else:\r
+                    pres.black()\r
             elif data["action"] == "white":\r
-                if current_slideshow:\r
-                    if current_slideshow.visible() == 4:\r
-                        current_slideshow.normal()\r
-                    else:\r
-                        current_slideshow.white()\r
-                notify_state()\r
+                if pres.state() == 4  or (\r
+                    config.prefs["Main"]["blackwhite"] == "both" and pres.state() == 3):\r
+                    pres.normal()\r
+                else:\r
+                    pres.white()\r
             elif data["action"] == "goto":\r
-                if current_slideshow:\r
-                    current_slideshow.goto(int(data["value"]))\r
-                notify_state()\r
+                pres.goto(int(data["value"]))\r
+                # Advancing to the black screen before the slideshow ends doesn't trigger \r
+                # ApplicationEvents.OnSlideShowNextSlide, so we have to check for that here and\r
+                # broadcast the new state if necessary. A delay is required since the event is \r
+                # triggered before the slideshow is actually closed, and we don't want to attempt\r
+                # to check the current slide of a slideshow that isn't running.\r
+                time.sleep(DELAY_FINAL)\r
+                if (pres.get_slideshow() is not None and \r
+                        pres.slide_current() == pres.slide_total() + 1):\r
+                    logger.debug("Jumped to black slide before end")\r
+                    broadcast_presentation(pres)\r
+            elif data["action"] == "start":\r
+                pres.start_slideshow()\r
+            elif data["action"] == "stop":\r
+                pres.stop_slideshow()\r
             else:\r
                 logger.error("Received unnsupported event: {}", data)\r
     finally:\r
-        USERS.remove(websocket)\r
+        users.remove(websocket)\r
 \r
 async def ws_send(websocket, path):\r
+    """\r
+    Broadcast data to all WebSockets clients\r
+    """\r
     while True:\r
         message = await ws_queue.get()\r
-        await asyncio.wait([user.send(message) for user in USERS])\r
+        await asyncio.wait([user.send(message) for user in users])\r
+\r
+\r
+def run_http():\r
+    """\r
+    Start the HTTP server\r
+    """\r
+    global my_http_server\r
+    my_http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)\r
+    my_http_server.serve_forever()\r
 \r
 \r
 def run_ws():\r
+    """\r
+    Set up threading/async for WebSockets server\r
+    """\r
     # https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084\r
     # https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/\r
     pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)\r
@@ -221,177 +226,301 @@ def run_ws():
     asyncio.get_event_loop().run_until_complete(start_server)\r
     asyncio.get_event_loop().run_forever()\r
 \r
-def start_http():\r
+def setup_http():\r
+    """\r
+    Set up threading for HTTP server\r
+    """\r
     http_daemon = threading.Thread(name="http_daemon", target=run_http)\r
     http_daemon.setDaemon(True)\r
     http_daemon.start()\r
     logger.info("Started HTTP server")\r
 \r
-def restart_http():\r
-    global http_server\r
-    if http_server:\r
-        http_server.shutdown()\r
-        http_server = None\r
-        refresh_status()\r
-    start_http()\r
-    time.sleep(0.5)\r
-    refresh_status()\r
-\r
-def start_ws():\r
+def setup_ws():\r
+    """\r
+    Set up threading for WebSockets server\r
+    """\r
     global ws_daemon\r
     ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)\r
     ws_daemon.setDaemon(True)\r
     ws_daemon.start()\r
     logger.info("Started websocket server")\r
+    \r
 \r
-class Slideshow:\r
-    def __init__(self, instance, blackwhite):\r
-        self.instance = instance\r
-        if self.instance is None:\r
-            raise ValueError("PPT instance cannot be None")\r
+def broadcast_presentation(pres):\r
+    """\r
+    Broadcast the state of a single presentation to all connected clients. Also ensures the current\r
+    slide and the two upcoming slides are exported and cached.\r
+    """\r
+    name = pres.presentation.Name\r
+    pres_open = name in ppt_presentations\r
+    slideshow = pres.slideshow is not None\r
+    visible = pres.state()\r
+    slide_current = pres.slide_current()\r
+    slide_total = pres.slide_total()\r
+\r
+    pres.export_current_next()\r
+    \r
+    if users:   # asyncio.wait doesn't accept an empty list\r
+        state = {"name": name, "pres_open": pres_open, "slideshow": slideshow, "visible": visible,\r
+                    "slide_current": slide_current, "slide_total": slide_total}\r
+        loop.call_soon_threadsafe(ws_queue.put_nowait, json.dumps({"type": "state", **state}))\r
+\r
+class ApplicationEvents:\r
+    """\r
+    Events assigned to the root application.\r
+    Ref: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application#events\r
+    """\r
+    def OnSlideShowNextSlide(self, window, *args):\r
+        """\r
+        Triggered when the current slide number of any slideshow is incremented, locally or through\r
+        ppt_control.\r
+        """\r
+        logger.debug("Slide advanced for {}".format(window.Presentation.Name))\r
+        broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
 \r
-        if self.instance.SlideShowWindows.Count == 0:\r
-            raise ValueError("PPT instance has no slideshow windows")\r
-        self.view = self.instance.SlideShowWindows[0].View\r
+    def OnSlideShowPrevSlide(self, window, *args):\r
+        """\r
+        Triggered when the current slide number of any slideshow is decremented, locally or through\r
+        ppt_control.\r
+        """\r
+        logger.debug("Slide decremented for {}".format(window.Presentation.Name))\r
+        broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
+    \r
+    def OnAfterPresentationOpen(self, presentation, *args):\r
+        """\r
+        Triggered when an existing presentation is opened. This adds the newly opened presentation\r
+        to the list of open presentations.\r
+        """\r
+        logger.debug("Presentation {} opened - adding to list".format(presentation.Name))\r
+        global ppt_presentations\r
+        ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)\r
+        broadcast_presentation(ppt_presentations[presentation.Name])\r
+        disable_protected_attempted.discard(presentation.Name)\r
+        icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)\r
+\r
+    def OnAfterNewPresentation(self, presentation, *args):\r
+        """\r
+        Triggered when a new presentation is opened. This adds the new presentation to the list\r
+        of open presentations.\r
+        """\r
+        logger.debug("Presentation {} opened (blank) - adding to list".format(presentation.Name))\r
+        global ppt_presentations\r
+        ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)\r
+        broadcast_presentation(ppt_presentations[presentation.Name])\r
+        icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)\r
 \r
-        if self.instance.ActivePresentation is None:\r
-            raise ValueError("PPT instance has no  active presentation")\r
-        self.presentation = self.instance.ActivePresentation\r
+    def OnPresentationClose(self, presentation, *args):\r
+        """\r
+        Triggered when a presentation is closed. This removes the presentation from the list of\r
+        open presentations. A delay is included to make sure the presentation is \r
+        actually closed, since the event is called simultaneously as the presentation is removed\r
+        from PowerPoint's internal structure. Ref:\r
+        https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application.presentationclose\r
+        """\r
+        logger.debug("Presentation {} closed - removing from list".format(presentation.Name))\r
+        global ppt_presentations\r
+        time.sleep(DELAY_CLOSE)\r
+        broadcast_presentation(ppt_presentations.pop(presentation.Name))\r
+        icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)\r
 \r
-        self.blackwhite = blackwhite\r
+    def OnSlideShowBegin(self, window, *args):\r
+        """\r
+        Triggered when a slideshow is started. This initialises the Slideshow object in the \r
+        appropriate Presentation object.\r
+        """\r
+        logger.debug("Slideshow started for {}".format(window.Presentation.Name))\r
+        global ppt_presentations\r
+        ppt_presentations[window.Presentation.Name].slideshow = window\r
+        broadcast_presentation(ppt_presentations[window.Presentation.Name])\r
+    \r
+    def OnSlideShowEnd(self, presentation, *args):\r
+        """\r
+        Triggered when a slideshow is ended. This deinitialises the Slideshow object in the \r
+        appropriate Presentation object.\r
+        """\r
+        logger.debug("Slideshow ended for {}".format(presentation.Name))\r
+        global ppt_presentations\r
+        ppt_presentations[presentation.Name].slideshow = None\r
+        broadcast_presentation(ppt_presentations[presentation.Name])\r
+\r
+\r
+class Presentation:\r
+    """\r
+    Class representing an instance of PowerPoint with a file open (so-called "presentation"\r
+    in PowerPoint terms). Mostly just a wrapper for PowerPoint's `Presentation` object.\r
+    """\r
+    def __init__(self, application, pres_index=None, pres_obj=None):\r
+        """\r
+        Initialise a Presentation object.\r
+            application     The PowerPoint application which the presentation is being run within\r
+            pres_index      PowerPoint's internal presentation index (NOTE this is indexed from 1)\r
+        """\r
+        if pres_index == None and pres_obj == None:\r
+            raise ValueError("Cannot initialise a presentation without a presentation ID or object")\r
+        assert len(application.Presentations) > 0, "Cannot initialise presentation from application with no presentations"\r
 \r
-        self.export_current_next()\r
+        self.__application = application\r
+        if pres_obj is not None:\r
+            self.presentation = pres_obj\r
+        else:\r
+            self.presentation = application.Presentations(pres_index)\r
+        self.slideshow = self.get_slideshow()\r
 \r
-    def unload(self):\r
-        connect_ppt()\r
 \r
-    def refresh(self):\r
+    def get_slideshow(self):\r
+        """\r
+        Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow.\r
+        """\r
         try:\r
-            if self.instance is None:\r
-                raise ValueError("PPT instance cannot be None")\r
+            return self.presentation.SlideShowWindow\r
+        except pywintypes.com_error as exc:\r
+            logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc))\r
+            return None\r
 \r
-            if self.instance.SlideShowWindows.Count == 0:\r
-                raise ValueError("PPT instance has no slideshow windows")\r
-            self.view = self.instance.SlideShowWindows[0].View\r
+    def start_slideshow(self):\r
+        """\r
+        Start the slideshow. Updating the state of this object is managed by the OnSlideshowBegin \r
+        event of the applicable Application.\r
+        """\r
+        if self.get_slideshow() is None:\r
+            self.presentation.SlideShowSettings.Run()\r
+        else:\r
+            logger.warning("Cannot start slideshow that is already running (presentation {})".format(\r
+                self.presentation.Name))\r
 \r
-            if self.instance.ActivePresentation is None:\r
-                raise ValueError("PPT instance has no  active presentation")\r
-        except:\r
-            self.unload()\r
+    def stop_slideshow(self):\r
+        """\r
+        Stop the slideshow. Updating the state of this object is managed by the OnSlideshowEnd\r
+        event of the applicable Application.\r
+        """\r
+        if self.get_slideshow() is not None:\r
+            self.presentation.SlideShowWindow.View.Exit()\r
+        else:\r
+            logger.warning("Cannot stop slideshow that is not running (presentation {})".format(\r
+                self.presentation.Name))\r
 \r
-    def total_slides(self):\r
-        try:\r
-            self.refresh()\r
-            return len(self.presentation.Slides)\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def state(self):\r
+        """\r
+        Returns the visibility state of the slideshow:\r
+        1: running\r
+        2: paused\r
+        3: black\r
+        4: white\r
+        5: done\r
+        Source: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate\r
+        """\r
+        if self.slideshow is not None:\r
+            return self.slideshow.View.State\r
+        else:\r
+            return 0\r
 \r
-    def current_slide(self):\r
-        try:\r
-            self.refresh()\r
-            return self.view.CurrentShowPosition\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def slide_current(self):\r
+        """\r
+        Returns the current slide number of the slideshow, or 0 if no slideshow is running.\r
+        """\r
+        if self.slideshow is not None:\r
+            return self.slideshow.View.CurrentShowPosition\r
+        else:\r
+            return 0\r
 \r
-    def visible(self):\r
-        try:\r
-            self.refresh()\r
-            return self.view.State\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def slide_total(self):\r
+        """\r
+        Returns the total number of slides in the presentation, regardless of whether a slideshow\r
+        is running.\r
+        """\r
+        return self.presentation.Slides.Count\r
 \r
     def prev(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.Previous()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the previous slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.Previous()\r
 \r
     def next(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.Next()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the previous slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.Next()\r
 \r
     def first(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.First()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the first slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.First()\r
                 \r
     def last(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.Last()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to the last slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.Last()\r
 \r
     def goto(self, slide):\r
-        try:\r
-            self.refresh()\r
-            if slide <= self.total_slides():\r
-                self.view.GotoSlide(slide)\r
-            else:\r
-                self.last()\r
-                self.next()\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
-\r
-    def black(self):\r
-        try:\r
-            self.refresh()\r
-            if self.blackwhite == "both" and self.view.State == 4:\r
-                self.view.state = 1\r
-            else:\r
-                self.view.State = 3\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
-\r
-    def white(self):\r
-        try:\r
-            self.refresh()\r
-            if self.blackwhite == "both" and self.view.State == 3:\r
-                self.view.state = 1\r
-            else:\r
-                self.view.State = 4\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Go to a numbered slide if there is a slideshow running. Notifying clients of the new state\r
+        is managed by the ApplicationEvent.\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        if slide <= self.slide_total():\r
+            self.slideshow.View.GotoSlide(slide)\r
+        else:\r
+            self.last()\r
+            self.next()\r
 \r
     def normal(self):\r
-        try:\r
-            self.refresh()\r
-            self.view.State = 1\r
-            self.export_current_next()\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+        """\r
+        Make the slideshow visible if there is a slideshow running. Note this puts the slideshow into \r
+        "running" state rather than the normal "paused" to ensure animations work correctly and the \r
+        slide is actually visible after changing the state. The state is normally returned to \r
+        "paused" automatically by PPT when advancing to the following slide. State enumeration ref: \r
+        https://docs.microsoft.com/en-us/office/vba/api/powerpoint.ppslideshowstate\r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.State = 1\r
+        broadcast_presentation(self)\r
 \r
-    def name(self):\r
-        try:\r
-            self.refresh()\r
-            return self.presentation.Name\r
-        except (ValueError, pywintypes.com_error):\r
-            self.unload()\r
+    def black(self):\r
+        """\r
+        Make the slideshow black if there is a slideshow running. \r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.State = 3\r
+        broadcast_presentation(self)\r
 \r
+    def white(self):\r
+        """\r
+        Make the slideshow white if there is a slideshow running. \r
+        """\r
+        assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)\r
+        self.slideshow.View.State = 4\r
+        broadcast_presentation(self)\r
 \r
     def export_current_next(self):\r
-        self.export(self.current_slide())\r
-        self.export(self.current_slide() + 1)\r
-        self.export(self.current_slide() + 2)\r
+        """\r
+        Export the current slide, the next slide, and the one after (ensures enough images are \r
+        always cached)\r
+        """\r
+        self.export(self.slide_current())\r
+        self.export(self.slide_current() + 1)\r
+        self.export(self.slide_current() + 2)\r
 \r
     def export(self, slide):\r
-        destination = config.prefs["Main"]["cache"] + "\\" + self.name() + "\\" + str(slide) + ".jpg"\r
+        """\r
+        Export a relatively low-resolution image of a slide using PowerPoint's built-in export \r
+        function. The cache destination is set in the config.\r
+        """\r
+        destination = config.prefs["Main"]["cache"] + "\\" + self.presentation.Name + "\\" + str(slide) + "." + config.prefs["Main"]["cache_format"].lower()\r
+        logger.debug("Exporting slide {} of {}".format(slide, self.presentation.Name))\r
         os.makedirs(os.path.dirname(destination), exist_ok=True)\r
         if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):\r
-            if slide <= self.total_slides():\r
+            if slide <= self.slide_total():\r
                 attempts = 0\r
                 while attempts < 3:\r
                     try:\r
@@ -400,192 +529,203 @@ class Slideshow:
                     except:\r
                         pass\r
                     attempts += 1\r
-            elif slide == self.total_slides() + 1:\r
+            elif slide == self.slide_total() + 1:\r
                 try:\r
                     shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))\r
-                except Exception as e:\r
-                    logger.warning("Failed to copy black slide: " + str(e))\r
+                except Exception as exc:\r
+                    logger.warning("Failed to copy black slide (number {} for presentation {}): {}".format(slide, self.presentation.Name, exc))\r
             else:\r
                 pass\r
 \r
     def export_all(self):\r
-        for i in range(1, self.total_slides()):\r
+        """\r
+        Export all slides in the presentation\r
+        """\r
+        for i in range(1, self.slide_total() + 2):\r
             self.export(i)\r
 \r
-def get_ppt_instance():\r
-    instance = win32com.client.Dispatch('Powerpoint.Application')\r
-    if instance is None or instance.SlideShowWindows.Count == 0:\r
-        return None\r
-    return instance\r
-\r
-def get_current_slideshow():\r
-    return current_slideshow\r
+def null_action(*args):\r
+    """\r
+    Placeholder for disabled menu items in systray\r
+    """\r
+    pass\r
 \r
-def refresh_interval():\r
+def edit_config(*args):\r
+    """\r
+    Open the config file for editing in Notepad, and create the directory if not existing\r
+    """\r
+    logger.debug("Opening config {}".format(pkg_base.CONFIG_PATH))\r
+    if not os.path.exists(pkg_base.CONFIG_DIR):\r
+        try:\r
+            os.makedirs(pkg_base.CONFIG_DIR)\r
+            logger.info("Made directory {}".format(pkg_base.CONFIG_DIR))\r
+        except Exception as exc:\r
+            logger.warning("Failed to create directory {} for config file".format(\r
+                pkg_base.CONFIG_DIR))\r
+            icon.notify("Create {} manually".format((pkg_base.CONFIG_DIR[:40] + '...') \r
+                        if len(pkg_base.CONFIG_DIR) > 40 else pkg_base.CONFIG_DIR),\r
+                        "Failed to create config directory")\r
+    try:\r
+        os.popen("notepad.exe {}".format(pkg_base.CONFIG_PATH))\r
+    except Exception as exc:\r
+        logger.warning("Failed to edit config {}: {}".format(pkg_base.CONFIG_PATH, exc))\r
+        icon.notify("Edit {} manually".format((pkg_base.CONFIG_PATH[:40] + '...') \r
+                        if len(pkg_base.CONFIG_PATH) > 40 else pkg_base.CONFIG_PATH),\r
+                        "Failed to open config")\r
+                \r
+def refresh():\r
+    """\r
+    Clear COM events and update interface elements at an interval defined in "refresh" in the config\r
+    TODO: fix "argument of type 'com_error' is not iterable"\r
+    """\r
     while getattr(refresh_daemon, "do_run", True):\r
-        current_slideshow.refresh()\r
-        notify_state()\r
-        refresh_status()\r
-        time.sleep(0.5)\r
-\r
-def refresh_status():\r
-    if interface_root is not None and interface_root.state == "normal":\r
-        if status_label is not None:\r
-            status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") +  "connected")\r
-        if http_label is not None:\r
-            http_label.config(text="HTTP server: " + ("not " if http_server is None else "") +  "running")\r
-            #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") +  "running")\r
-        if reset_ppt_button is not None:\r
-            reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)\r
-\r
-def connect_ppt():\r
-    global STATE\r
-    global refresh_daemon\r
-    if STATE["connected"] == 1:\r
-        logger.info("Disconnected from PowerPoint instance")\r
-        icon.notify("Disconnected from PowerPoint instance")\r
-        if reset_ppt_button is not None:\r
-            reset_ppt_button.config(state = tk.DISABLED)\r
-        refresh_daemon.do_run = False\r
-        STATE = copy(STATE_DEFAULT)\r
-        if icon is not None:\r
-            refresh_menu()\r
-        refresh_status()\r
-        logger.debug("State is now " + str(STATE))\r
-    while True:\r
         try:\r
-            instance = get_ppt_instance()\r
-            global current_slideshow\r
-            current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])\r
-            STATE["connected"] = 1\r
-            STATE["current"] = current_slideshow.current_slide()\r
-            STATE["total"] = current_slideshow.total_slides()\r
-            icon.notify("Connected to PowerPoint instance")\r
-            if icon is not None:\r
-                refresh_menu()\r
-            refresh_status()\r
-            logger.info("Connected to PowerPoint instance")\r
-            refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)\r
-            refresh_daemon.setDaemon(True)\r
-            refresh_daemon.start()\r
-            break\r
-        except ValueError as e:\r
-            current_slideshow = None\r
-            pass\r
-        time.sleep(1)\r
-\r
-def start(_=None):\r
-    start_http()\r
-    start_ws()\r
-    connect_ppt()\r
-\r
-def on_closing():\r
-    global status_label\r
-    global http_label\r
-    global ws_label\r
-    global interface_thread\r
-    status_label = None\r
-    http_label = None\r
-    ws_label = None\r
-    logger.debug("Destroying interface root")\r
-    interface_root.destroy()\r
-    logger.debug("Destroying interface thread")\r
-    interface_thread.root.quit()\r
-    interface_thread = None\r
-    \r
-def open_settings(_=None):\r
-    global interface_root\r
-    global interface_thread\r
-    interface_root = tk.Tk()\r
-    interface_root.protocol("WM_DELETE_WINDOW", on_closing)\r
-    interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))\r
-    interface_root.geometry("600x300+300+300")\r
-    app = Interface(interface_root)\r
-    interface_thread = threading.Thread(target=interface_root.mainloop())\r
-    interface_thread.setDaemon(True)\r
-    interface_thread.start()\r
-\r
-def null_action():\r
-    pass\r
-\r
-def save_settings():\r
-    pass\r
-\r
-class Interface(ttk.Frame):\r
-\r
-    def __init__(self, parent):\r
-        ttk.Frame.__init__(self, parent)\r
-\r
-        self.parent = parent\r
-\r
-        self.initUI()\r
-\r
-    def initUI(self):\r
-        global status_label\r
-        global http_label\r
-        global ws_label\r
-        global reset_ppt_button\r
-        self.parent.title("ppt-control")\r
-        self.style = ttk.Style()\r
-        #self.style.theme_use("default")\r
-        self.focus_force()\r
-\r
-        self.pack(fill=tk.BOTH, expand=1)\r
-\r
-        quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)\r
-        quitButton.place(x=480, y=280)\r
-\r
-        save_button = ttk.Button(self, text="OK", command=save_settings)\r
-        save_button.place(x=400, y=280)\r
-\r
-        reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)\r
-        reset_ppt_button.config(state = tk.DISABLED)\r
-        reset_ppt_button.place(x=300, y=10)\r
-\r
-        reset_http_button = ttk.Button(self, text="Restart", command=restart_http)\r
-        reset_http_button.place(x=300, y=30)\r
+            pythoncom.PumpWaitingMessages()\r
+            # TODO: don't regenerate entire menu on each refresh, use pystray.Icon.update_menu()\r
+            icon.menu = (pystray.MenuItem("Status: " + "dis"*(len(ppt_presentations) == 0) + "connected",\r
+                lambda: null_action(), enabled=False),\r
+                pystray.MenuItem("Stop", lambda: exit(icon)),\r
+                pystray.MenuItem("Edit config", lambda: edit_config()))\r
+            manage_protected_view(ppt_application)\r
+            time.sleep(float(config.prefs["Main"]["refresh"]))\r
+        except Exception as exc:\r
+            # Deal with any exceptions, such as RPC server restarting, by reconnecting to application\r
+            # (if this fails again, that's okay because we'll keep trying until it works)\r
+            logger.error("Error whilst refreshing state: {}".format(exc))\r
+            app = get_application()\r
+        \r
 \r
-        reset_ws_button = ttk.Button(self, text="Restart", command=null_action)\r
-        reset_ws_button.place(x=300, y=50)\r
+def get_application():\r
+    """\r
+    Create an Application object representing the PowerPoint application installed on the machine.\r
+    This should succeed regardless of whether PowerPoint is running, as long as PowerPoint is\r
+    installed.\r
+    Returns the Application object if successful, otherwise returns None.\r
+    """\r
+    try:\r
+        return win32com.client.Dispatch('PowerPoint.Application')\r
+    except pywintypes.com_error:\r
+        # PowerPoint is probably not installed, or other COM failure\r
+        return None\r
+        \r
 \r
-        status_label = ttk.Label(self)\r
-        status_label.place(x=10,y=10)\r
+def manage_protected_view(app):\r
+    """\r
+    Attempt to unlock any presentations that have been opened in protected view. These cannot be\r
+    controlled by the program whilst they are in protected view, so we attempt to disable protected\r
+    view, or show a notification if this doesn't work for some reason.\r
+    """\r
+    try:\r
+        if app.ProtectedViewWindows.Count > 0:\r
+            logger.debug("Found open presentation(s) but at least one is in protected view")\r
+            for i in range(1, app.ProtectedViewWindows.Count + 1):  # +1 to account for indexing from 1\r
+                pres_name = app.ProtectedViewWindows(i).Presentation.Name\r
+                if pres_name in disable_protected_attempted:\r
+                    continue\r
+                if config.prefs.getboolean("Main", "disable_protected"):\r
+                    try:\r
+                        app.ProtectedViewWindows(i).Edit()\r
+                        logger.info("Enabled editing for {}".format(pres_name))\r
+                    except Exception as exc:\r
+                        icon.notify("Failed to disable protected view on \"{}\"".format((pres_name[:22] + '...') \r
+                        if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")\r
+                        logger.warning("Failed to disable protected view {} for editing - do this manually: {}".format(pres_name, exc))\r
+                        disable_protected_attempted.add(pres_name)\r
+                else:\r
+                    icon.notify("Cannot control \"{}\" in protected view".format((pres_name[:22] + '...') \r
+                        if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")\r
+                    logger.warning("Cannot control {} in protected view, and automatic disabling of protected view is turned off".format(pres_name))\r
+                    disable_protected_attempted.add(pres_name)\r
+    except Exception as exc:\r
+        if type(exc) == pywintypes.com_error and "application is busy" in exc:\r
+            # PowerPoint needs some time to finish loading file if it has just been opened,\r
+            # otherwise we get "The message filter indicated that the application is busy". Here,\r
+            # we deal with this by gracefully ignoring any protected view windows until the next \r
+            # refresh cycle, when PowerPoint is hopefully finished loading (if the refresh interval\r
+            # is sufficiently long).\r
+            logger.debug("COM interface not taking requests right now - will try again on next refresh")\r
+            return\r
+        # Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients"\r
+        # at this point.\r
+        logger.warning("{} whilst dealing with protected view windows: {}".format(type(exc), exc))\r
+        app = get_application()\r
 \r
-        http_label = ttk.Label(self)\r
-        http_label.place(x=10,y=30)\r
 \r
-        ws_label = ttk.Label(self)\r
-        ws_label.place(x=10,y=50)\r
 \r
-        refresh_status()\r
+def connect_ppt():\r
+    """\r
+    Connect to the PowerPoint COM interface and perform initial enumeration of open files \r
+    ("presentations"). Files that are subsequently opened are dealt with using COM events (see the\r
+    ApplicationEvents class above). Therefore, once we are finished setting things up, we just \r
+    call refresh() as a daemon in order to keep clients up to date.\r
+    """\r
+    logger.debug("Searching for a PowerPoint slideshow...")\r
+    global ppt_application\r
+    global ppt_presentations\r
+\r
+    # Initialise PowerPoint application\r
+    ppt_application = get_application()\r
+    if ppt_application is None:\r
+        # Couldn't find PowerPoint application\r
+        icon.notify("Couldn't find PowerPoint application", "Error starting {}".format(PKG_NAME))\r
+        logger.error("Couldn't find PowerPoint application - check that PowerPoint is installed and COM is working")\r
+        sys.exit()\r
+\r
+    # Continue because we can connect to PowerPoint\r
+    logger.debug("Found PowerPoint application")\r
+\r
+    # Dispatch events\r
+    win32com.client.WithEvents(ppt_application, ApplicationEvents)\r
+    logger.debug("Dispatched events")\r
+\r
+    # Deal with windows in protected view\r
+    manage_protected_view(ppt_application)\r
+\r
+    # Initial enumeration of open presentations\r
+    logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations)))\r
+    for n in range(1, len(ppt_application.Presentations)+1):    # PowerPoint's slide indexing starts at 1.. why!?!?!?\r
+        pres = Presentation(ppt_application, n)\r
+        icon.notify("Connected to {}".format(pres.presentation.Name), PKG_NAME)\r
+        logger.debug("Found presentation {} with index {}".format(pres.presentation.Name, n))\r
+        ppt_presentations[pres.presentation.Name] = pres\r
+    refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh)\r
+    refresh_daemon.setDaemon(True)\r
+    logger.debug("Handing over to refresh daemon - goodbye...")\r
+    if len(ppt_presentations) == 0:\r
+        # Provide some confirmation that the program has started if we haven't sent any \r
+        # connection notifications yet\r
+        icon.notify("Started server", PKG_NAME)\r
+    refresh_daemon.start()\r
+\r
+\r
+def start_server(_=None):\r
+    """\r
+    Start HTTP and WS servers, then connect to PPT instance with connect_ppt() which will then \r
+    set off the refresh daemon.\r
+    """\r
+    setup_http()\r
+    setup_ws()\r
+    connect_ppt()\r
         \r
         \r
-def exit_action(icon):\r
+def exit(icon):\r
+    """\r
+    Clean up and exit when user clicks "Stop" from systray menu\r
+    """\r
     logger.debug("User requested shutdown")\r
     icon.visible = False\r
     icon.stop()\r
 \r
-def refresh_menu():\r
-    icon.menu = (pystray.MenuItem("Status: " + "dis"*(not STATE["connected"]) + "connected", lambda: null_action(), enabled=False),\r
-            pystray.MenuItem("Stop", lambda: exit_action(icon)),\r
-            pystray.MenuItem("Settings", lambda: open_settings())\r
-            )\r
-\r
-def show_icon():\r
-    global icon\r
-    logger.debug("Starting system tray icon")\r
-    icon = pystray.Icon("ppt-control")\r
-    icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')\r
-    icon.title = "ppt-control"\r
-    refresh_menu()\r
-    icon.visible = True\r
-    icon.run(setup=start)\r
 \r
 def start_interface():\r
+    """\r
+    Main entrypoint for the program. Loads config and logging, starts systray icon, and calls\r
+    start_server() to start the backend.\r
+    """\r
+    global icon\r
     global logger\r
-\r
     # Load config\r
-    config.prefs = config.loadconf(CONFIG_FILE)\r
+    config.prefs = config.loadconf(pkg_base.CONFIG_PATH)\r
 \r
     # Set up logging\r
     if config.prefs["Main"]["logging"] == "debug":\r
@@ -601,16 +741,26 @@ def start_interface():
     logger.setLevel(log_level)\r
     logger.propagate = False\r
 \r
-    file_handler = logging.FileHandler("{0}/{1}".format(os.getenv("APPDATA"), LOGFILE))\r
-    file_handler.setFormatter(log_formatter)\r
-    file_handler.setLevel(log_level)\r
-    logger.addHandler(file_handler)\r
-\r
     console_handler = logging.StreamHandler()\r
     console_handler.setFormatter(log_formatter)\r
     console_handler.setLevel(log_level)\r
     logger.addHandler(console_handler)\r
 \r
+    if not os.path.exists(pkg_base.LOG_DIR):\r
+        try:\r
+            os.makedirs(pkg_base.LOG_DIR)\r
+            logger.info("Made directory {}".format(pkg_base.LOG_DIR))\r
+        except Exception as exc:\r
+            logger.warning("Failed to create directory {} for log".format(\r
+                pkg_base.LOG_DIR))\r
+            icon.notify("Create {} manually".format((pkg_base.LOG_DIR[:40] + '...') \r
+                        if len(pkg_base.LOG_DIR) > 40 else pkg_base.LOG_DIR),\r
+                        "Failed to create log directory")\r
+    file_handler = logging.FileHandler(pkg_base.LOG_PATH)\r
+    file_handler.setFormatter(log_formatter)\r
+    file_handler.setLevel(log_level)\r
+    logger.addHandler(file_handler)\r
+\r
     #logging.getLogger("asyncio").setLevel(logging.ERROR)\r
     #logging.getLogger("asyncio.coroutines").setLevel(logging.ERROR)\r
     logging.getLogger("websockets.server").setLevel(logging.ERROR)\r
@@ -620,7 +770,15 @@ def start_interface():
     logger.debug("Finished setting up config and logging")\r
 \r
     # Start systray icon and server\r
-    show_icon()\r
+    logger.debug("Starting system tray icon")\r
+    icon = MyIcon(PKG_NAME)\r
+    icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')\r
+    icon.title = "{} {}".format(PKG_NAME, PKG_VERSION)\r
+    #refresh_menu(icon)\r
+    icon.visible = True\r
+    icon.run(setup=start_server)\r
+\r
+    # Exit when icon has stopped\r
     sys.exit(0)\r
 \r
 if __name__ == "__main__":\r