From 5ecf59062c026e3c63e23aab5990615a5cc0d856 Mon Sep 17 00:00:00 2001
From: Andrew Lorimer
Date: Sun, 15 Aug 2021 22:46:12 +1000
Subject: [PATCH] Major rewrite of main module
Uses more event-driven syntax in main module to reduce refreshes, and
now supports multiple presentations (on the server side at least... a
bit more work needed in JS).
---
ppt_control/config.py | 4 +-
ppt_control/ppt_control.py | 873 +++++++++++++++---------------
ppt_control/static/index.html | 3 +-
ppt_control/static/ppt-control.js | 149 +++--
ppt_control/static/style.css | 4 +
5 files changed, 549 insertions(+), 484 deletions(-)
diff --git a/ppt_control/config.py b/ppt_control/config.py
index e0416a4..74df1f3 100644
--- a/ppt_control/config.py
+++ b/ppt_control/config.py
@@ -11,7 +11,9 @@ defaults = {
'cache_format': 'JPG',
'cache_timeout': 5*60,
'cache_init': True,
- 'blackwhite': 'both'
+ 'blackwhite': 'both',
+ 'refresh': 2,
+ 'disable_protected': False
},
'HTTP': {
'interface': '',
diff --git a/ppt_control/ppt_control.py b/ppt_control/ppt_control.py
index 8502bcb..2b4d6f6 100644
--- a/ppt_control/ppt_control.py
+++ b/ppt_control/ppt_control.py
@@ -4,7 +4,6 @@ import win32com.client
import pywintypes
import os
import shutil
-#import http.server as server
import socketserver
import threading
import asyncio
@@ -16,51 +15,46 @@ import posixpath
import time
import pythoncom
import pystray
-import tkinter as tk
-from tkinter import ttk
from PIL import Image
from copy import copy
-import ppt_control.http_server_39 as server
+import ppt_control.__init__ as pkg_base
+import ppt_control.http_server_39 as http_server # 3.9 version of the HTTP server (details in module)
import ppt_control.config as config
logging.basicConfig()
global http_daemon
+http_daemon = None
+global http_server
+http_server = None
global ws_daemon
-global STATE
-global STATE_DEFAULT
-global current_slideshow
-global interface_root
-global interface_thread
+ws_daemon = None
+global users
+users = set()
global logger
+logger = None
global refresh_daemon
-global status_label
-global http_label
-global ws_label
-global reset_ppt_button
-global http_server
+refresh_daemon = None
global icon
-scheduler = None
-current_slideshow = None
-interface_root = None
-interface_thread = None
+icon = None
+global ppt_application
+ppt_application = None
+global ppt_presentations
+ppt_presentations = {}
+global disable_protected_attempted
+disable_protected_attempted = set()
+
+PKG_NAME = pkg_base.__name__
+PKG_VERSION = pkg_base.__version__
CONFIG_FILE = r'''..\ppt-control.ini'''
LOGFILE = r'''..\ppt-control.log'''
-REFRESH_INTERVAL = 2
-logger = None
-refresh_daemon = None
-status_label = None
-http_label = None
-ws_label = None
-ws_daemon = None
-http_server = None
-reset_ppt_button = None
-icon = None
-ws_stop_event = False
-class Handler(server.SimpleHTTPRequestHandler):
+class Handler(http_server.SimpleHTTPRequestHandler):
+ """
+ Custom handler to translate /cache* urls to the cache directory (set in the config)
+ """
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=os.path.dirname(os.path.realpath(__file__)) + r'''\static''')
@@ -89,16 +83,11 @@ class Handler(server.SimpleHTTPRequestHandler):
words = path.split('/')
words = list(filter(None, words))
if len(words) > 0 and words[0] == "cache":
- black = 0
- if current_slideshow:
- try:
- path = config.prefs["Main"]["cache"] + "\\" + current_slideshow.name()
- except Exception as e:
- path = "black.jpg"
- logger.warning("Failed to get current slideshow name: ", e)
+ if words[1] in ppt_presentations:
+ path = config.prefs["Main"]["cache"]
else:
path = "black.jpg"
- return path
+ logger.warning("Request for cached file {} for non-existent presentation".format(path))
words.pop(0)
else:
path = self.directory
@@ -111,42 +100,10 @@ class Handler(server.SimpleHTTPRequestHandler):
path += '/'
return path
-
-def run_http():
- global http_server
- http_server = server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)
- http_server.serve_forever()
-
-STATE_DEFAULT = {"connected": 0, "current": 0, "total": 0, "visible": 0, "name": ""}
-STATE = copy(STATE_DEFAULT)
-USERS = set()
-
-
-def state_event():
- return json.dumps({"type": "state", **STATE})
-
-
-def notify_state():
- logger.debug("Notifying state")
- global STATE
- if current_slideshow and STATE["connected"] == 1:
- try:
- STATE["current"] = current_slideshow.current_slide()
- STATE["total"] = current_slideshow.total_slides()
- STATE["visible"] = current_slideshow.visible()
- STATE["name"] = current_slideshow.name()
- except Exception as e:
- logger.info("Failed to update state variables, presumably PPT instance doesn't exist anymore: {}".format(e))
- current_slideshow.unload()
- else:
- STATE = copy(STATE_DEFAULT)
- if USERS: # asyncio.wait doesn't accept an empty list
- message = state_event()
- loop.call_soon_threadsafe(ws_queue.put_nowait, state_event())
-
-
-
async def ws_handler(websocket, path):
+ """
+ Handle a WebSockets connection
+ """
logger.debug("Handling WebSocket connection")
recv_task = asyncio.ensure_future(ws_receive(websocket, path))
send_task = asyncio.ensure_future(ws_send(websocket, path))
@@ -158,60 +115,65 @@ async def ws_handler(websocket, path):
task.cancel()
async def ws_receive(websocket, path):
- logger.debug("Received websocket request")
- USERS.add(websocket)
+ """
+ Process data received on the WebSockets connection
+ """
+ users.add(websocket)
try:
# Send initial state to clients on load
- notify_state()
+ for pres in ppt_presentations:
+ broadcast_presentation(ppt_presentations[pres])
async for message in websocket:
logger.debug("Received websocket message: " + str(message))
data = json.loads(message)
+ pres = ppt_presentations[data["presentation"]]
if data["action"] == "prev":
- if current_slideshow:
- current_slideshow.prev()
- #notify_state()
+ pres.prev()
elif data["action"] == "next":
- if current_slideshow:
- current_slideshow.next()
- #notify_state()
+ pres.next()
elif data["action"] == "first":
- if current_slideshow:
- current_slideshow.first()
- #notify_state()
+ pres.first()
elif data["action"] == "last":
- if current_slideshow:
- current_slideshow.last()
- #notify_state()
+ pres.last()
elif data["action"] == "black":
- if current_slideshow:
- if current_slideshow.visible() == 3:
- current_slideshow.normal()
- else:
- current_slideshow.black()
- #notify_state()
+ if pres.state() == 3:
+ pres.normal()
+ else:
+ pres.black()
elif data["action"] == "white":
- if current_slideshow:
- if current_slideshow.visible() == 4:
- current_slideshow.normal()
- else:
- current_slideshow.white()
- #notify_state()
+ if pres.state() == 4:
+ pres.normal()
+ else:
+ pres.white()
elif data["action"] == "goto":
- if current_slideshow:
- current_slideshow.goto(int(data["value"]))
- #notify_state()
+ pres.goto(int(data["value"]))
else:
logger.error("Received unnsupported event: {}", data)
finally:
- USERS.remove(websocket)
+ users.remove(websocket)
async def ws_send(websocket, path):
+ """
+ Broadcast data to all WebSockets clients
+ """
while True:
message = await ws_queue.get()
- await asyncio.wait([user.send(message) for user in USERS])
+ await asyncio.wait([user.send(message) for user in users])
+
+
+def run_http():
+ """
+ Start the HTTP server
+ """
+ global http_server
+ http_server = http_server.HTTPServer((config.prefs["HTTP"]["interface"], config.prefs.getint("HTTP", "port")), Handler)
+ http_server.serve_forever()
def run_ws():
+ """
+ Set up threading/async for WebSockets server
+ """
# https://stackoverflow.com/questions/21141217/how-to-launch-win32-applications-in-separate-threads-in-python/22619084#22619084
# https://www.reddit.com/r/learnpython/comments/mwt4qi/pywintypescom_error_2147417842_the_application/
pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED)
@@ -224,208 +186,273 @@ def run_ws():
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
-def start_http():
+def setup_http():
+ """
+ Set up threading for HTTP server
+ """
http_daemon = threading.Thread(name="http_daemon", target=run_http)
http_daemon.setDaemon(True)
http_daemon.start()
logger.info("Started HTTP server")
-def restart_http():
- global http_server
- if http_server:
- http_server.shutdown()
- http_server = None
- refresh_status()
- start_http()
- time.sleep(0.5)
- refresh_status()
-
-def start_ws():
+def setup_ws():
+ """
+ Set up threading for WebSockets server
+ """
global ws_daemon
ws_daemon = threading.Thread(name="ws_daemon", target=run_ws)
ws_daemon.setDaemon(True)
ws_daemon.start()
logger.info("Started websocket server")
+
-def restart_ws():
- global ws_daemon
- global ws_stop_event
- if ws_daemon and not ws_stop_event:
- ws_stop_event = True
- logger.debug("Stopped WebSocket server")
- refresh_status()
- #ws_daemon = None
- time.sleep(2)
- #start_ws()
- refresh_status()
+def broadcast_presentation(pres):
+ """
+ Broadcast the state of a single presentation to all connected clients. Also ensures the current
+ slide and the two upcoming slides are exported and cached.
+ """
+ name = pres.presentation.Name
+ pres_open = name in ppt_presentations
+ slideshow = pres.slideshow is not None
+ visible = pres.state()
+ slide_current = pres.slide_current()
+ slide_total = pres.slide_total()
+
+ pres.export_current_next()
+ if users: # asyncio.wait doesn't accept an empty list
+ state = {"name": name, "pres_open": pres_open, "slideshow": slideshow, "visible": visible,
+ "slide_current": slide_current, "slide_total": slide_total}
+ loop.call_soon_threadsafe(ws_queue.put_nowait, json.dumps({"type": "state", **state}))
class ApplicationEvents:
- def OnSlideShowNextSlide(self, *args):
- notify_state()
- logger.debug("Slide changed")
- current_slideshow.export_current_next()
-
- def OnSlideShowPrevSlide(self, *args):
- notify_state()
- logger.debug("Slide changed")
- current_slideshow.export_current_next()
-
-class Slideshow:
- def __init__(self, instance, blackwhite):
- self.instance = instance
- if self.instance is None:
- raise ValueError("PPT instance cannot be None")
-
- if self.instance.SlideShowWindows.Count == 0:
- raise ValueError("PPT instance has no slideshow windows")
- self.view = self.instance.SlideShowWindows(1).View
-
- if self.instance.ActivePresentation is None:
- raise ValueError("PPT instance has no active presentation")
- self.presentation = self.instance.ActivePresentation
-
- self.blackwhite = blackwhite
-
- if config.prefs["Main"]["cache_init"]:
- self.export_all()
- else:
- self.export_current_next()
+ """
+ Events assigned to the root application.
+ Ref: https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application#events
+ """
+ def OnSlideShowNextSlide(self, window, *args):
+ """
+ Triggered when the current slide number of any slideshow is incremented, locally or through
+ ppt_control.
+ """
+ logger.debug("Slide advanced for {}".format(window.Presentation.Name))
+ broadcast_presentation(ppt_presentations[window.Presentation.Name])
- events = win32com.client.WithEvents(win32com.client.GetActiveObject("Powerpoint.Application"), ApplicationEvents)
- logger.debug("Dispatched events")
+ def OnSlideShowPrevSlide(self, window, *args):
+ """
+ Triggered when the current slide number of any slideshow is decremented, locally or through
+ ppt_control.
+ """
+ logger.debug("Slide decremented for {}".format(window.Presentation.Name))
+ broadcast_presentation(ppt_presentations[window.Presentation.Name])
+
+ def OnAfterPresentationOpen(self, presentation, *args):
+ """
+ Triggered when an existing presentation is opened. This adds the newly opened presentation
+ to the list of open presentations.
+ """
+ logger.debug("Presentation {} opened - adding to list".format(presentation.Name))
+ global ppt_presentations
+ ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)
+ broadcast_presentation(ppt_presentations[presentation.Name])
+ disable_protected_attempted.discard(presentation.Name)
+ icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)
+
+ def OnAfterNewPresentation(self, presentation, *args):
+ """
+ Triggered when a new presentation is opened. This adds the new presentation to the list
+ of open presentations.
+ """
+ logger.debug("Presentation {} opened - adding to list".format(presentation.Name))
+ global ppt_presentations
+ ppt_presentations[presentation.Name] = Presentation(ppt_application, pres_obj=presentation)
+ broadcast_presentation(ppt_presentations[presentation.Name])
+ icon.notify("Connected to {}".format(presentation.Name), PKG_NAME)
- def unload(self):
- connect_ppt()
+ def OnPresentationClose(self, presentation, *args):
+ """
+ Triggered when a presentation is closed. This removes the presentation from the list of
+ open presentations. A delay of 200 ms is included to make sure the presentation is
+ actually closed, since the event is called simultaneously as the presentation is removed
+ from PowerPoint's internal structure. Ref:
+ https://docs.microsoft.com/en-us/office/vba/api/powerpoint.application.presentationclose
+ """
+ logger.debug("Presentation {} closed - removing from list".format(presentation.Name))
+ global ppt_presentations
+ time.sleep(0.2)
+ broadcast_presentation(ppt_presentations.pop(presentation.Name))
+ icon.notify("Disconnected from {}".format(presentation.Name), PKG_NAME)
- def refresh(self):
- try:
- if self.instance is None:
- raise ValueError("PPT instance cannot be None")
+ def OnSlideShowBegin(self, window, *args):
+ """
+ Triggered when a slideshow is started. This initialises the Slideshow object in the
+ appropriate Presentation object.
+ """
+ logger.debug("Slideshow started for {}".format(window.Presentation.Name))
+ global ppt_presentations
+ ppt_presentations[window.Presentation.Name].slideshow = window
+ broadcast_presentation(ppt_presentations[window.Presentation.Name])
+
+ def OnSlideShowEnd(self, presentation, *args):
+ """
+ Triggered when a slideshow is ended. This deinitialises the Slideshow object in the
+ appropriate Presentation object.
+ """
+ logger.debug("Slideshow ended for {}".format(presentation.Name))
+ global ppt_presentations
+ ppt_presentations[presentation.Name].slideshow = None
+ broadcast_presentation(ppt_presentations[presentation.Name])
+
+
+class Presentation:
+ """
+ Class representing an instance of PowerPoint with a file open (so-called "presentation"
+ in PowerPoint terms). Mostly just a wrapper for PowerPoint's `Presentation` object.
+ """
+ def __init__(self, application, pres_index=None, pres_obj=None):
+ """
+ Initialise a Presentation object.
+ application The PowerPoint application which the presentation is being run within
+ pres_index PowerPoint's internal presentation index (NOTE this is indexed from 1)
+ """
+ if pres_index == None and pres_obj == None:
+ raise ValueError("Cannot initialise a presentation without a presentation ID or object")
+ assert len(application.Presentations) > 0, "Cannot initialise presentation from application with no presentations"
- if self.instance.SlideShowWindows.Count == 0:
- raise ValueError("PPT instance has no slideshow windows")
- self.view = self.instance.SlideShowWindows(1).View
+ self.__application = application
+ if pres_obj is not None:
+ self.presentation = pres_obj
+ else:
+ self.presentation = application.Presentations(pres_index)
+ self.slideshow = self.get_slideshow()
- if self.instance.ActivePresentation is None:
- raise ValueError("PPT instance has no active presentation")
- except:
- self.unload()
- def total_slides(self):
+ def get_slideshow(self):
+ """
+ Check whether the presentation is in slideshow mode, and if so, return the SlideShowWindow.
+ """
try:
- self.refresh()
- return len(self.presentation.Slides)
- except (ValueError, pywintypes.com_error):
- self.unload()
+ return self.presentation.SlideShowWindow
+ except pywintypes.com_error as exc:
+ logger.debug("Couldn't get slideshow for {}: {}".format(self.presentation.Name, exc))
+ return None
- def current_slide(self):
- try:
- self.refresh()
- return self.view.CurrentShowPosition
- except (ValueError, pywintypes.com_error):
- self.unload()
+ def state(self):
+ """
+ Returns the visibility state of the slideshow:
+ 2: normal
+ 3: black
+ 4: white
+ 5: done
+ """
+ if self.slideshow is not None:
+ return self.slideshow.View.State
+ else:
+ return 2
- def visible(self):
- try:
- self.refresh()
- return self.view.State
- except (ValueError, pywintypes.com_error):
- self.unload()
+ def slide_current(self):
+ """
+ Returns the current slide number of the slideshow, or 0 if no slideshow is running.
+ """
+ if self.slideshow is not None:
+ return self.slideshow.View.CurrentShowPosition
+ else:
+ return 0
+
+ def slide_total(self):
+ """
+ Returns the total number of slides in the presentation, regardless of whether a slideshow
+ is running.
+ """
+ return self.presentation.Slides.Count
def prev(self):
- try:
- self.refresh()
- self.view.Previous()
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
+ """
+ Go to the previous slide if there is a slideshow running. Notifying clients of the new state
+ is managed by the ApplicationEvent.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.Previous()
def next(self):
- try:
- self.refresh()
- self.view.Next()
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
+ """
+ Go to the previous slide if there is a slideshow running. Notifying clients of the new state
+ is managed by the ApplicationEvent.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.Next()
def first(self):
- try:
- self.refresh()
- self.view.First()
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
+ """
+ Go to the first slide if there is a slideshow running. Notifying clients of the new state
+ is managed by the ApplicationEvent.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.First()
def last(self):
- try:
- self.refresh()
- self.view.Last()
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
+ """
+ Go to the last slide if there is a slideshow running. Notifying clients of the new state
+ is managed by the ApplicationEvent.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.Last()
def goto(self, slide):
- try:
- self.refresh()
- if slide <= self.total_slides():
- self.view.GotoSlide(slide)
- else:
- self.last()
- self.next()
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
-
- def black(self):
- try:
- self.refresh()
- if self.blackwhite == "both" and self.view.State == 4:
- self.view.state = 1
- else:
- self.view.State = 3
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
-
- def white(self):
- try:
- self.refresh()
- if self.blackwhite == "both" and self.view.State == 3:
- self.view.state = 1
- else:
- self.view.State = 4
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
+ """
+ Go to a numbered slide if there is a slideshow running. Notifying clients of the new state
+ is managed by the ApplicationEvent.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ if slide <= self.slide_total():
+ self.slideshow.View.GotoSlide(slide)
+ else:
+ self.last()
+ self.next()
def normal(self):
- try:
- self.refresh()
- self.view.State = 1
- self.export_current_next()
- except (ValueError, pywintypes.com_error):
- self.unload()
+ """
+ Make the slideshow visible if there is a slideshow running.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.State = 2
+ broadcast_presentation(self)
- def name(self):
- try:
- self.refresh()
- return self.presentation.Name
- except (ValueError, pywintypes.com_error):
- self.unload()
+ def black(self):
+ """
+ Make the slideshow black if there is a slideshow running.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.State = 3
+ broadcast_presentation(self)
+ def white(self):
+ """
+ Make the slideshow white if there is a slideshow running.
+ """
+ assert self.slideshow is not None, "Slideshow is not running for {}".format(self.presentation.Name)
+ self.slideshow.View.State = 4
+ broadcast_presentation(self)
def export_current_next(self):
- self.export(self.current_slide())
- self.export(self.current_slide() + 1)
- self.export(self.current_slide() + 2)
+ """
+ Export the current slide, the next slide, and the one after (ensures enough images are
+ always cached)
+ """
+ self.export(self.slide_current())
+ self.export(self.slide_current() + 1)
+ self.export(self.slide_current() + 2)
def export(self, slide):
- destination = config.prefs["Main"]["cache"] + "\\" + self.name() + "\\" + str(slide) + ".jpg"
- logger.debug("Exporting slide " + str(slide))
+ """
+ Export a relatively low-resolution image of a slide using PowerPoint's built-in export
+ function. The cache destination is set in the config.
+ """
+ destination = config.prefs["Main"]["cache"] + "\\" + self.presentation.Name + "\\" + str(slide) + "." + config.prefs["Main"]["cache_format"].lower()
+ logger.debug("Exporting slide {} of {}".format(slide, self.presentation.Name))
os.makedirs(os.path.dirname(destination), exist_ok=True)
if not os.path.exists(destination) or time.time() - os.path.getmtime(destination) > config.prefs.getint("Main", "cache_timeout"):
- if slide <= self.total_slides():
+ if slide <= self.slide_total():
attempts = 0
while attempts < 3:
try:
@@ -434,200 +461,168 @@ class Slideshow:
except:
pass
attempts += 1
- elif slide == self.total_slides() + 1:
+ elif slide == self.slide_total() + 1:
try:
shutil.copyfileobj(open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\black.jpg''', 'rb'), open(destination, 'wb'))
- except Exception as e:
- logger.warning("Failed to copy black slide: " + str(e))
+ except Exception as exc:
+ logger.warning("Failed to copy black slide (number {} for presentation {}): {}".format(slide, self.presentation.Name, exc))
else:
pass
def export_all(self):
- for i in range(1, self.total_slides() + 2):
+ """
+ Export all slides in the presentation
+ """
+ for i in range(1, self.slide_total() + 2):
self.export(i)
-def get_ppt_instance():
- instance = win32com.client.Dispatch('Powerpoint.Application')
- if instance is None or instance.SlideShowWindows.Count == 0:
- return None
- return instance
-
-def get_current_slideshow():
- return current_slideshow
-
-def refresh_interval():
+def null_action(*args):
+ """
+ Placeholder for disabled menu items in systray
+ """
+ pass
+
+def refresh():
+ """
+ Clear COM events and update interface elements at an interval defined in "refresh" in the config
+ """
while getattr(refresh_daemon, "do_run", True):
- logger.debug("Refreshing general")
- pythoncom.PumpWaitingMessages()
- current_slideshow.refresh()
- if current_slideshow.visible != STATE["visible"]:
- notify_state()
- #refresh_status()
- time.sleep(REFRESH_INTERVAL)
-
-def refresh_status():
- if interface_root is not None:
- logger.debug("Refreshing UI")
- if status_label is not None:
- status_label.config(text="PowerPoint status: " + ("not " if not STATE["connected"] else "") + "connected")
- if http_label is not None:
- http_label.config(text="HTTP server: " + ("not " if http_server is None else "") + "running")
- #ws_label.config(text="WebSocket server: " + ("not " if ws_daemon is not None or not ws_daemon.is_alive() else "") + "running")
- if reset_ppt_button is not None:
- reset_ppt_button.config(state = tk.DISABLED if not STATE["connected"] else tk.NORMAL)
-
-def connect_ppt():
- global STATE
- global refresh_daemon
- if STATE["connected"] == 1:
- logger.info("Disconnected from PowerPoint instance")
- icon.notify("Disconnected from PowerPoint instance")
- if reset_ppt_button is not None:
- reset_ppt_button.config(state = tk.DISABLED)
- refresh_daemon.do_run = False
- STATE = copy(STATE_DEFAULT)
- if icon is not None:
- refresh_menu()
- refresh_status()
- logger.debug("State is now " + str(STATE))
- while True:
try:
- instance = get_ppt_instance()
- global current_slideshow
- current_slideshow = Slideshow(instance, config.prefs["Main"]["blackwhite"])
- STATE["connected"] = 1
- STATE["current"] = current_slideshow.current_slide()
- STATE["total"] = current_slideshow.total_slides()
- icon.notify("Connected to PowerPoint instance")
- if icon is not None:
- refresh_menu()
- refresh_status()
- logger.info("Connected to PowerPoint instance")
- refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh_interval)
- refresh_daemon.setDaemon(True)
- refresh_daemon.start()
- break
- except ValueError as e:
- current_slideshow = None
- pass
- time.sleep(1)
-
-def start(_=None):
- start_http()
- start_ws()
- connect_ppt()
-
-def on_closing():
- global status_label
- global http_label
- global ws_label
- global interface_thread
- status_label = None
- http_label = None
- ws_label = None
- logger.debug("Destroying interface root")
- interface_root.destroy()
- logger.debug("Destroying interface thread")
- interface_thread.root.quit()
- interface_thread = None
-
-def open_settings(_=None):
- global interface_root
- global interface_thread
- if interface_root is None:
- interface_root = tk.Tk()
- interface_root.protocol("WM_DELETE_WINDOW", on_closing)
- interface_root.iconphoto(False, tk.PhotoImage(file=os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.png'''))
- interface_root.geometry("600x300+300+300")
- app = Interface(interface_root)
- interface_thread = threading.Thread(target=interface_root.mainloop())
- interface_thread.setDaemon(True)
- interface_thread.start()
-
-def null_action():
- pass
-
-def save_settings():
- pass
-
-class Interface(ttk.Frame):
+ pythoncom.PumpWaitingMessages()
+ icon.menu = (pystray.MenuItem("Status: " + "dis"*(len(ppt_presentations) == 0) + "connected",
+ lambda: null_action(), enabled=False),
+ pystray.MenuItem("Stop", lambda: exit(icon)))
+ manage_protected_view(ppt_application)
+ time.sleep(float(config.prefs["Main"]["refresh"]))
+ except Exception as exc:
+ # Deal with any exceptions, such as RPC server restarting, by reconnecting to application
+ # (if this fails again, that's okay because we'll keep trying until it works)
+ logger.error("Error whilst refreshing state: {}".format(exc))
+ app = get_application()
- def __init__(self, parent):
- ttk.Frame.__init__(self, parent)
-
- self.parent = parent
-
- self.initUI()
-
- def initUI(self):
- global status_label
- global http_label
- global ws_label
- global reset_ppt_button
- self.parent.title("ppt-control")
- self.style = ttk.Style()
- #self.style.theme_use("default")
- self.focus_force()
-
- self.pack(fill=tk.BOTH, expand=1)
-
- quitButton = ttk.Button(self, text="Cancel", command=interface_root.destroy)
- quitButton.place(x=480, y=280)
-
- save_button = ttk.Button(self, text="OK", command=save_settings)
- save_button.place(x=400, y=280)
-
- reset_ppt_button = ttk.Button(self, text="Reconnect", command=connect_ppt)
- reset_ppt_button.config(state = tk.DISABLED)
- reset_ppt_button.place(x=300, y=10)
-
- reset_http_button = ttk.Button(self, text="Restart", command=restart_http)
- reset_http_button.place(x=300, y=30)
+
- #reset_ws_button = ttk.Button(self, text="Restart", command=restart_ws)
- #reset_ws_button.place(x=300, y=50)
+def get_application():
+ """
+ Create an Application object representing the PowerPoint application installed on the machine.
+ This should succeed regardless of whether PowerPoint is running, as long as PowerPoint is
+ installed.
+ Returns the Application object if successful, otherwise returns None.
+ """
+ try:
+ return win32com.client.Dispatch('PowerPoint.Application')
+ except pywintypes.com_error:
+ # PowerPoint is probably not installed, or other COM failure
+ return None
+
- status_label = ttk.Label(self)
- status_label.place(x=10,y=10)
+def manage_protected_view(app):
+ """
+ Attempt to unlock any presentations that have been opened in protected view. These cannot be
+ controlled by the program whilst they are in protected view, so we attempt to disable protected
+ view, or show a notification if this doesn't work for some reason.
+ """
+ try:
+ if app.ProtectedViewWindows.Count > 0:
+ logger.debug("Found open presentation(s) but at least one is in protected view")
+ for i in range(1, app.ProtectedViewWindows.Count + 1): # +1 to account for indexing from 1
+ pres_name = app.ProtectedViewWindows(i).Presentation.Name
+ if pres_name in disable_protected_attempted:
+ continue
+ if config.prefs.getboolean("Main", "disable_protected"):
+ try:
+ app.ProtectedViewWindows(i).Edit()
+ logger.info("Enabled editing for {}".format(pres_name))
+ except Exception as exc:
+ icon.notify("Failed to disable protected view on \"{}\"".format((pres_name[:22] + '...')
+ if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")
+ logger.warning("Failed to disable protected view {} for editing - do this manually: {}".format(pres_name, exc))
+ disable_protected_attempted.add(pres_name)
+ else:
+ icon.notify("Cannot control \"{}\" in protected view".format((pres_name[:22] + '...')
+ if len(pres_name) > 25 else pres_name), "Disable protected view in PowerPoint")
+ logger.warning("Cannot control {} in protected view, and automatic disabling of protected view is turned off".format(pres_name))
+ disable_protected_attempted.add(pres_name)
+ except Exception as exc:
+ # Sometimes gets pywintypes.com_error "The object invoked has disconnected from its clients"
+ # at this point which leads to "Exception whilst dealing with protected view windows".
+ logger.warning("Exception whilst dealing with protected view windows: {}".format(exc))
+ app = get_application()
- http_label = ttk.Label(self)
- http_label.place(x=10,y=30)
- ws_label = ttk.Label(self)
- ws_label.place(x=10,y=50)
- refresh_status()
+def connect_ppt():
+ """
+ Connect to the PowerPoint COM interface and perform initial enumeration of open files
+ ("presentations"). Files that are subsequently opened are dealt with using COM events (see the
+ ApplicationEvents class above). Therefore, once we are finished setting things up, we just
+ call refresh() as a daemon in order to keep clients up to date.
+ """
+ logger.debug("Searching for a PowerPoint slideshow...")
+ global ppt_application
+ global ppt_presentations
+
+ # Initialise PowerPoint application
+ ppt_application = get_application()
+ if ppt_application is None:
+ # Couldn't find PowerPoint application
+ icon.notify("Couldn't find PowerPoint application", "Error starting {}".format(PKG_NAME))
+ logger.error("Couldn't find PowerPoint application - check that PowerPoint is installed and COM is working")
+ sys.exit()
+
+ # Continue because we can connect to PowerPoint
+ logger.debug("Found PowerPoint application")
+
+ # Dispatch events
+ win32com.client.WithEvents(ppt_application, ApplicationEvents)
+ logger.debug("Dispatched events")
+
+ # Deal with windows in protected view
+ manage_protected_view(ppt_application)
+
+ # Initial enumeration of open presentations
+ logger.debug("Enumerating {} presentation(s)".format(len(ppt_application.Presentations)))
+ for n in range(1, len(ppt_application.Presentations)+1): # PowerPoint's slide indexing starts at 1.. why!?!?!?
+ pres = Presentation(ppt_application, n)
+ icon.notify("Connected to {}".format(pres.presentation.Name), PKG_NAME)
+ logger.debug("Found presentation {} with index {}".format(pres.presentation.Name, n))
+ ppt_presentations[pres.presentation.Name] = pres
+ refresh_daemon = threading.Thread(name="refresh_daemon", target=refresh)
+ refresh_daemon.setDaemon(True)
+ logger.debug("Handing over to refresh daemon - goodbye...")
+ if len(ppt_presentations) == 0:
+ # Provide some confirmation that the program has started if we haven't sent any
+ # connection notifications yet
+ icon.notify("Started server", PKG_NAME)
+ refresh_daemon.start()
+
+
+def start_server(_=None):
+ """
+ Start HTTP and WS servers, then connect to PPT instance with connect_ppt() which will then
+ set off the refresh daemon.
+ """
+ setup_http()
+ setup_ws()
+ connect_ppt()
-def exit_action(icon):
+def exit(icon):
+ """
+ Clean up and exit when user clicks "Stop" from systray menu
+ """
logger.debug("User requested shutdown")
- if interface_root is not None:
- try:
- interface_root.destroy()
- except:
- pass
icon.visible = False
icon.stop()
-def refresh_menu():
- icon.menu = (pystray.MenuItem("Status: " + "dis"*(not STATE["connected"]) + "connected", lambda: null_action(), enabled=False),
- pystray.MenuItem("Stop", lambda: exit_action(icon)),
- pystray.MenuItem("Settings", lambda: open_settings(), enabled=True)
- )
-
-def show_icon():
- global icon
- logger.debug("Starting system tray icon")
- icon = pystray.Icon("ppt-control")
- icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')
- icon.title = "ppt-control"
- refresh_menu()
- icon.visible = True
- icon.run(setup=start)
def start_interface():
+ """
+ Main entrypoint for the program. Loads config and logging, starts systray icon, and calls
+ start_server() to start the backend.
+ """
+ global icon
global logger
-
# Load config
config.prefs = config.loadconf(CONFIG_FILE)
@@ -664,8 +659,16 @@ def start_interface():
logger.debug("Finished setting up config and logging")
# Start systray icon and server
- show_icon()
+ logger.debug("Starting system tray icon")
+ icon = pystray.Icon(PKG_NAME)
+ icon.icon = Image.open(os.path.dirname(os.path.realpath(__file__)) + r'''\static\icons\ppt.ico''')
+ icon.title = "{} {}".format(PKG_NAME, PKG_VERSION)
+ #refresh_menu(icon)
+ icon.visible = True
+ icon.run(setup=start_server)
+
+ # Exit when icon has stopped
sys.exit(0)
if __name__ == "__main__":
- start_interface()
+ start_interface()
\ No newline at end of file
diff --git a/ppt_control/static/index.html b/ppt_control/static/index.html
index 21d08af..685c789 100644
--- a/ppt_control/static/index.html
+++ b/ppt_control/static/index.html
@@ -29,7 +29,7 @@
- Current: /?
+ Current: /?