From: Andrew Lorimer Date: Sun, 15 Aug 2021 12:46:12 +0000 (+1000) Subject: Major rewrite of main module X-Git-Tag: v0.0.4~4 X-Git-Url: https://git.lorimer.id.au/ppt-control.git/diff_plain/5ecf59062c026e3c63e23aab5990615a5cc0d856 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). --- 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: /?

@@ -39,6 +39,7 @@ Keyboard shortcuts

Disconnected

+

Presentation:

diff --git a/ppt_control/static/ppt-control.js b/ppt_control/static/ppt-control.js index f884d90..1001b5d 100644 --- a/ppt_control/static/ppt-control.js +++ b/ppt_control/static/ppt-control.js @@ -2,7 +2,8 @@ var DEFAULT_TITLE = "ppt-control" var preloaded = false; var preload = []; -var prev = document.querySelector('#prev'), +var presentation = document.querySelector('#presentation'), + prev = document.querySelector('#prev'), next = document.querySelector('#next'), first = document.querySelector('#first'), last = document.querySelector('#last'), @@ -12,6 +13,7 @@ var prev = document.querySelector('#prev'), current = document.querySelector('#current'), total = document.querySelector('#total'), status_text = document.querySelector('.status_text'), + presentation_text = document.querySelector('.presentation_text'), current_img = document.querySelector('#current_img'), next_img = document.querySelector('#next_img'), current_div = document.querySelector('#current_div'), @@ -22,6 +24,17 @@ var prev = document.querySelector('#prev'), show_next = document.querySelector('#show_next'), shortcuts = document.querySelector('#shortcuts'); +var presentations_list = {}; + + +function getPresentationName() { + if (presentation.selectedOptions.length > 0) { + return presentation.selectedOptions[0].innerText; + } else { + return ""; + } +} + function startWebsocket() { console.log("Attempting to connect") @@ -40,31 +53,45 @@ function startWebsocket() { var websocket = startWebsocket(); prev.onclick = function (event) { - websocket.send(JSON.stringify({action: 'prev'})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'prev'})); + } } next.onclick = function (event) { - websocket.send(JSON.stringify({action: 'next'})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'next'})); + } } first.onclick = function (event) { - websocket.send(JSON.stringify({action: 'first'})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'first'})); + } } last.onclick = function (event) { - websocket.send(JSON.stringify({action: 'last'})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'last'})); + } } black.onclick = function (event) { - websocket.send(JSON.stringify({action: 'black'})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'black'})); + } } white.onclick = function (event) { - websocket.send(JSON.stringify({action: 'white'})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'white'})); + } } current.onblur = function (event) { - websocket.send(JSON.stringify({action: 'goto', value: current.value})); + if (getPresentationName()) { + websocket.send(JSON.stringify({presentation: getPresentationName(), action: 'goto', value: current.value})); + } } current.addEventListener('keyup',function(e){ @@ -159,6 +186,42 @@ document.addEventListener('keydown', function (e) { } }); +function refreshInterface() { + var d = new Date; + currentPresentationData = presentations_list[getPresentationName()]; + presentation_text.style.display = "block"; + status_text.textContent = "Slideshow running"; + if (show_current.checked) { + switch (currentPresentationData.visible) { + case 3: + current_img.src = "/black.jpg"; + break; + case 4: + current_img.src = "/white.jpg"; + break; + default: + current_img.src = "/cache/" + currentPresentationData.name + "/" + currentPresentationData.slide_current + ".jpg?t=" + d.getTime(); + break; + } + } + if (currentPresentationData.slide_current == currentPresentationData.slide_total + 1) { + next_img.src = "/black.jpg"; + } else { + next_img.src = "/cache/" + currentPresentationData.name + "/" + (currentPresentationData.slide_current + 1) + ".jpg?t=" + d.getTime(); + } + if (currentPresentationData.slide_current == 0) { + current_img.src = "/black.jpg"; + next_img.src = "/black.jpg"; + status_text.textContent = "Slideshow not running"; + } + + if (document.activeElement != current) { + current.value = currentPresentationData.slide_current; + } + total.textContent = currentPresentationData.slide_total; + document.title = currentPresentationData.name; +} + function disconnect() { console.log("Disconnecting") document.title = DEFAULT_TITLE; @@ -167,55 +230,47 @@ function disconnect() { status_text.textContent = "Disconnected"; total.textContent = "?"; current.value = ""; + presentation_text.style.display = "none"; } function receive_message(event) { data = JSON.parse(event.data); - switch (data.type) { - case 'state': - if (data.connected == "0" || data.connected == 0) { - disconnect(); - break; - } else { - status_text.textContent = "Connected"; - } - var d = new Date; - if (show_current.checked) { - switch (data.visible) { - case 3: - current_img.src = "/black.jpg"; - break; - case 4: - current_img.src = "/white.jpg"; - break; - default: - current_img.src = "/cache/" + data.current + ".jpg?t=" + d.getTime(); - //current_img.src = "/cache/" + data.current + ".jpg"; - break; - } - } - if (data.current == data.total + 1) { - next_img.src = "/cache/" + (data.total + 1) + ".jpg?t=" + d.getTime(); - //next_img.src = "/cache/" + (data.total + 1) + ".jpg"; - } else { - next_img.src = "/cache/" + (data.current + 1) + ".jpg?t=" + d.getTime(); - //next_img.src = "/cache/" + (data.current + 1) + ".jpg"; - } + presentations_list[data.name] = {"name": data.name, "pres_open": data.pres_open, "slideshow": data.slideshow, "visible": data.visible, "slide_current": data.slide_current, "slide_total": data.slide_total}; + if (! presentations_list[data.name].pres_open) { + delete presentations_list[data.name]; + if (Object.keys(presentations_list).length == 0) { + disconnect(); + } else { + status_text.textContent = "Connected"; + } + } else { + + var presentation_dropdown = Array.from(presentation.children); - if (document.activeElement != current) { - current.value = data.current; + var found = false; + for(var i = 0; i < presentation_dropdown.length; i++) { + if (presentation_dropdown[i].textContent == data.name) { + if (! data.pres_open) { + presentation_dropdown[i].remove() + } else { + found = true; + break; + } } - total.textContent = data.total; - document.title = data.name; - break; - default: - console.error( - "Unsupported event", data); + } + if (found == false) { + var dropdown_option = document.createElement("option"); + dropdown_option.textContent = data.name; + dropdown_option.value = data.name; + presentation.appendChild(dropdown_option); + } + refreshInterface() } + if (preloaded == false && ! isNaN(total.textContent)) { image = document.getElementById("preload_img"); for (let i=1; i<=Number(total.textContent); i++) { - image.src = "/cache/" + i + ".jpg"; + image.src = "/cache/" + getPresentationName() + "/" + i + ".jpg"; preload.push(image); } preloaded = true; diff --git a/ppt_control/static/style.css b/ppt_control/static/style.css index 01c5734..018e863 100644 --- a/ppt_control/static/style.css +++ b/ppt_control/static/style.css @@ -75,3 +75,7 @@ button { input[type='checkbox'] { font-size: 15px; } + +.presentation_text { + display: none; +}