/** * PPT Remote Server * Controls PowerPoint (Windows) or LibreOffice Impress (Linux) via WebSocket. * * Commands (client → server): * { "cmd": "next" } * { "cmd": "prev" } * { "cmd": "start" } — start slideshow * { "cmd": "end" } — end slideshow * { "cmd": "status" } — request current slide info * * Events (server → client): * { "event": "slide", "current": 3, "total": 10 } * { "event": "image", "data": "" } * { "event": "error", "message": "..." } */ const PORT = 8765; const IS_LINUX = process.platform === "linux"; // ─── Helpers ────────────────────────────────────────────────────────────────── async function run(cmd: string, args: string[]): Promise { const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe" }); const [out, err] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); await proc.exited; if (err.trim()) console.error(`[stderr]`, err.trim()); return out.trim(); } function parseSlide(raw: string): { current: number; total: number } { const [c, t] = raw.split("/").map(Number); return { current: c || 0, total: t || 0 }; } // ─── Driver interface ───────────────────────────────────────────────────────── interface Driver { next(): Promise<{ current: number; total: number }>; prev(): Promise<{ current: number; total: number }>; start(): Promise<{ current: number; total: number }>; end(): Promise<{ current: number; total: number }>; status(): Promise<{ current: number; total: number }>; slideImage(index: number): Promise; // base64 jpeg/png } // ─── Windows Driver (PowerShell + COM) ─────────────────────────────────────── function ps(script: string) { return run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]); } const WindowsDriver: Driver = { async next() { const raw = await ps(` $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') $app.ActivePresentation.SlideShowWindow.View.Next() Start-Sleep -Milliseconds 300 $v = $app.ActivePresentation.SlideShowWindow.View "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)" `); return parseSlide(raw); }, async prev() { const raw = await ps(` $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') $app.ActivePresentation.SlideShowWindow.View.Previous() Start-Sleep -Milliseconds 300 $v = $app.ActivePresentation.SlideShowWindow.View "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)" `); return parseSlide(raw); }, async start() { const raw = await ps(` $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') $app.ActivePresentation.SlideShowSettings.Run() | Out-Null Start-Sleep -Milliseconds 800 $v = $app.ActivePresentation.SlideShowWindow.View "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)" `); return parseSlide(raw); }, async end() { const raw = await ps(` $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') $app.ActivePresentation.SlideShowWindow.View.Exit() "0/$($app.ActivePresentation.Slides.Count)" `); return parseSlide(raw); }, async status() { const raw = await ps(` try { $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') $total = $app.ActivePresentation.Slides.Count try { $v = $app.ActivePresentation.SlideShowWindow.View "$($v.CurrentShowPosition)/$total" } catch { "0/$total" } } catch { "0/0" } `); return parseSlide(raw); }, async slideImage(index: number) { return ps(` try { $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application') $slide = $app.ActivePresentation.Slides(${index}) $tmp = [System.IO.Path]::GetTempFileName() -replace '\\.tmp$','.jpg' $slide.Export($tmp, 'JPG', 800, 600) $bytes = [System.IO.File]::ReadAllBytes($tmp) [System.Convert]::ToBase64String($bytes) Remove-Item $tmp -ErrorAction SilentlyContinue } catch { Write-Error $_.Exception.Message; "" } `); }, }; // ─── Linux Driver (Python UNO + xdotool) ───────────────────────────────────── // // Prerequisites: // - LibreOffice Impress started with UNO socket listener: // libreoffice --impress --accept="socket,host=localhost,port=2002;urp;StarOffice.ServiceManager" // - python3 with uno module (ships with LibreOffice) // - xdotool (sudo apt install xdotool) // // The Python helper talks to LibreOffice over UNO to read slide state. // Navigation uses xdotool to send keys to the Impress window (most reliable // method that works with both windowed and fullscreen slideshow modes). function pyUno(script: string): Promise { return run("python3", ["-c", script]); } /** Returns the window ID of the LibreOffice Impress window */ async function libreofficeWinId(): Promise { return run("xdotool", ["search", "--name", "LibreOffice Impress"]); } // Navigate via UNO — separate script per action to avoid indentation issues function pyUnoNav(direction: "next" | "prev" | "start" | "end"): Promise { // Helper snippet reused in next/prev to read position after nav const readPos = ` current = 0 controller = comp.getCurrentController() draw = comp.DrawPages total = draw.Count if hasattr(controller, 'getCurrentSlideIndex'): current = controller.getCurrentSlideIndex() + 1 print(f"[nav] slideshow mode, index={current}", file=sys.stderr) else: page = controller.getCurrentPage() for i in range(draw.Count): if draw.getByIndex(i) == page: current = i + 1 break print(f"[nav] editor mode, index={current}", file=sys.stderr) print(f"{current}/{total}") `; const scripts: Record = { next: ` import sys, time try: import uno localCtx = uno.getComponentContext() resolver = localCtx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", localCtx) ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) comp = desktop.getCurrentComponent() controller = comp.getCurrentController() draw = comp.DrawPages print(f"[nav] next, controller: {type(controller).__name__}", file=sys.stderr) if hasattr(controller, 'gotoNextSlide'): controller.gotoNextSlide() else: page = controller.getCurrentPage() idx = 0 for i in range(draw.Count): if draw.getByIndex(i) == page: idx = i break next_idx = min(idx + 1, draw.Count - 1) print(f"[nav] setCurrentPage {idx} -> {next_idx}", file=sys.stderr) controller.setCurrentPage(draw.getByIndex(next_idx)) time.sleep(0.3) ${readPos} except Exception as e: import traceback; traceback.print_exc(file=sys.stderr) print("0/0") `, prev: ` import sys, time try: import uno localCtx = uno.getComponentContext() resolver = localCtx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", localCtx) ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) comp = desktop.getCurrentComponent() controller = comp.getCurrentController() draw = comp.DrawPages print(f"[nav] prev, controller: {type(controller).__name__}", file=sys.stderr) if hasattr(controller, 'gotoPreviousSlide'): controller.gotoPreviousSlide() else: page = controller.getCurrentPage() idx = 0 for i in range(draw.Count): if draw.getByIndex(i) == page: idx = i break prev_idx = max(idx - 1, 0) print(f"[nav] setCurrentPage {idx} -> {prev_idx}", file=sys.stderr) controller.setCurrentPage(draw.getByIndex(prev_idx)) time.sleep(0.3) ${readPos} except Exception as e: import traceback; traceback.print_exc(file=sys.stderr) print("0/0") `, start: ` import sys, time try: import uno localCtx = uno.getComponentContext() resolver = localCtx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", localCtx) ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) comp = desktop.getCurrentComponent() pres = comp.Presentation print(f"[nav] starting presentation", file=sys.stderr) pres.start() time.sleep(1.5) controller = comp.getCurrentController() draw = comp.DrawPages total = draw.Count current = 0 print(f"[nav] controller after start: {type(controller).__name__}", file=sys.stderr) if hasattr(controller, 'getCurrentSlideIndex'): current = controller.getCurrentSlideIndex() + 1 else: try: page = controller.getCurrentPage() for i in range(draw.Count): if draw.getByIndex(i) == page: current = i + 1 break except: current = 1 print(f"[nav] start -> {current}/{total}", file=sys.stderr) print(f"{current}/{total}") except Exception as e: import traceback; traceback.print_exc(file=sys.stderr) print("0/0") `, end: ` import sys, time try: import uno localCtx = uno.getComponentContext() resolver = localCtx.ServiceManager.createInstanceWithContext("com.sun.star.bridge.UnoUrlResolver", localCtx) ctx = resolver.resolve("uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) comp = desktop.getCurrentComponent() controller = comp.getCurrentController() print(f"[nav] ending, controller: {type(controller).__name__}", file=sys.stderr) if hasattr(controller, 'deactivate'): controller.deactivate() else: comp.Presentation.end() time.sleep(0.3) total = comp.DrawPages.Count print(f"[nav] end -> 0/{total}", file=sys.stderr) print(f"0/{total}") except Exception as e: import traceback; traceback.print_exc(file=sys.stderr) print("0/0") `, }; return pyUno(scripts[direction]); } const LinuxDriver: Driver = { async next() { console.log("[linux] next: navigating via UNO"); const raw = await pyUnoNav("next"); console.log(`[linux] next raw: "${raw}"`); return parseSlide(raw); }, async prev() { console.log("[linux] prev: navigating via UNO"); const raw = await pyUnoNav("prev"); console.log(`[linux] prev raw: "${raw}"`); return parseSlide(raw); }, async start() { console.log("[linux] start: starting presentation via UNO"); const raw = await pyUnoNav("start"); console.log(`[linux] start raw: "${raw}"`); return parseSlide(raw); }, async end() { console.log("[linux] end: ending presentation via UNO"); const raw = await pyUnoNav("end"); console.log(`[linux] end raw: "${raw}"`); const { total } = parseSlide(raw); return { current: 0, total }; }, async status() { console.log("[linux] status: running UNO query"); const raw = await pyUno(` import sys try: import uno from com.sun.star.beans import PropertyValue localCtx = uno.getComponentContext() resolver = localCtx.ServiceManager.createInstanceWithContext( "com.sun.star.bridge.UnoUrlResolver", localCtx) ctx = resolver.resolve( "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) comp = desktop.getCurrentComponent() draw = comp.DrawPages total = draw.Count current = 0 try: controller = comp.getCurrentController() # During slideshow: PresentationController has getCurrentSlideIndex() if hasattr(controller, 'getCurrentSlideIndex'): current = controller.getCurrentSlideIndex() + 1 print(f"[status] slideshow mode, index={current}", file=sys.stderr) # Editor mode: DrawController has getCurrentPage() elif hasattr(controller, 'getCurrentPage'): page = controller.getCurrentPage() # page.PageIndex may not exist on all objects; use DrawPages index for i in range(draw.Count): if draw.getByIndex(i) == page: current = i + 1 break print(f"[status] editor mode, index={current}", file=sys.stderr) else: print(f"[status] unknown controller type: {type(controller)}", file=sys.stderr) except Exception as inner: print(f"[status] could not get current page: {inner}", file=sys.stderr) current = 0 print(f"{current}/{total}") except Exception as e: print(f"[status] UNO error: {e}", file=sys.stderr) print("0/0") `); console.log(`[linux] status raw output: "${raw}"`); const result = parseSlide(raw); console.log(`[linux] status parsed: current=${result.current} total=${result.total}`); return result; }, async slideImage(index: number) { console.log(`[linux] slideImage: exporting slide index=${index}`); const raw = await pyUno(` import sys, os, base64, tempfile try: import uno from com.sun.star.beans import PropertyValue localCtx = uno.getComponentContext() resolver = localCtx.ServiceManager.createInstanceWithContext( "com.sun.star.bridge.UnoUrlResolver", localCtx) ctx = resolver.resolve( "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext") smgr = ctx.ServiceManager desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx) comp = desktop.getCurrentComponent() draw = comp.DrawPages print(f"[slideImage] total slides: {draw.Count}", file=sys.stderr) slide = draw.getByIndex(${index - 1}) print(f"[slideImage] got slide: {slide.Name}", file=sys.stderr) tmp = tempfile.mktemp(suffix='.png') print(f"[slideImage] exporting to: {tmp}", file=sys.stderr) graphicExporter = smgr.createInstanceWithContext( "com.sun.star.drawing.GraphicExportFilter", ctx) # setSourceDocument must be called with the slide (DrawPage) graphicExporter.setSourceDocument(slide) def mkprop(name, val): p = PropertyValue() p.Name = name p.Value = val return p # Do NOT pass Selection — setSourceDocument is sufficient props = ( mkprop("URL", uno.systemPathToFileUrl(tmp)), mkprop("MediaType", "image/png"), mkprop("PixelWidth", 800), mkprop("PixelHeight", 600), ) graphicExporter.filter(props) if os.path.exists(tmp): size = os.path.getsize(tmp) print(f"[slideImage] exported file size: {size} bytes", file=sys.stderr) with open(tmp, 'rb') as f: data = f.read() b64 = base64.b64encode(data).decode() print(f"[slideImage] base64 length: {len(b64)}", file=sys.stderr) print(b64) os.unlink(tmp) else: print(f"[slideImage] export file not created at {tmp}", file=sys.stderr) print("") except Exception as e: import traceback print(f"[slideImage] exception: {e}", file=sys.stderr) traceback.print_exc(file=sys.stderr) print("") `); const trimmed = raw.trim(); console.log(`[linux] slideImage: got output length=${trimmed.length}${trimmed.length === 0 ? " (EMPTY — check stderr above)" : ""}`); return trimmed; }, }; // ─── Linux: LibreOffice process manager ────────────────────────────────────── // Serves a web file picker at http://:PORT/picker. // When a file is selected it POSTs to /picker/open, which launches LibreOffice. // Re-prompts (via broadcast) when LibreOffice exits. const UNO_PORT = 2002; let libreofficeProc: ReturnType | null = null; // Resolves when the user picks a file via the web UI let pickerResolve: ((path: string) => void) | null = null; function waitForFilePick(): Promise { return new Promise((resolve) => { pickerResolve = resolve; }); } const PICKER_HTML = ` Open Presentation — PPT Remote
📂

Select a Presentation — PPT Remote

⬆️ Drag & drop a presentation file here to open it
Loading…
`; async function servePickerRequest(req: Request): Promise { const url = new URL(req.url); if (url.pathname === "/picker") { const home = process.env.HOME || "/root"; const html = PICKER_HTML.replace("'__HOME__'", JSON.stringify(home)); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } if (url.pathname === "/picker/ls") { const home = process.env.HOME || "/root"; const dirPath = url.searchParams.get("path") || home; try { const entries: { name: string; path: string; dir: boolean }[] = []; const dir = await import("node:fs/promises"); const items = await dir.readdir(dirPath, { withFileTypes: true }); for (const entry of items) { if (entry.name.startsWith(".")) continue; entries.push({ name: entry.name, path: `${dirPath.replace(/\/$/, "")}/${entry.name}`, dir: entry.isDirectory(), }); } // dirs first, then files, both alphabetical entries.sort((a, b) => { if (a.dir !== b.dir) return a.dir ? -1 : 1; return a.name.localeCompare(b.name); }); return Response.json({ entries }); } catch (e) { return Response.json({ entries: [], error: String(e) }); } } if (url.pathname === "/picker/open" && req.method === "POST") { const { path } = await req.json() as { path: string }; if (!path) return Response.json({ ok: false, error: "No path provided" }); if (pickerResolve) { pickerResolve(path); pickerResolve = null; return Response.json({ ok: true }); } // No active picker waiting — launch directly (e.g. re-open after close) launchLibreOffice(path).catch(console.error); return Response.json({ ok: true }); } return null; // not a picker route } async function launchLibreOffice(filePath: string): Promise { console.log(`Opening: ${filePath}`); libreofficeProc = Bun.spawn([ "libreoffice", `--accept=socket,host=localhost,port=${UNO_PORT};urp;StarOffice.ServiceManager`, "--impress", filePath, ], { stdout: "inherit", stderr: "inherit" }); // Wait for UNO socket to become available (up to 10s) for (let i = 0; i < 20; i++) { await Bun.sleep(500); try { const status = await LinuxDriver.status(); if (status.total > 0) break; } catch { /* not ready yet */ } } broadcast({ event: "presentation", status: "opened", file: filePath.split("/").pop() }); // Watch for process exit, then re-prompt libreofficeProc.exited.then(async () => { console.log("LibreOffice closed."); broadcast({ event: "presentation", status: "closed" }); libreofficeProc = null; await promptAndLaunch(); }); } async function promptAndLaunch(): Promise { console.log(`Open a presentation at http://localhost:${PORT}/picker`); broadcast({ event: "presentation", status: "none", pickerUrl: `/picker` }); const file = await waitForFilePick(); await launchLibreOffice(file); } // ─── Select driver based on platform ───────────────────────────────────────── const driver: Driver = IS_LINUX ? LinuxDriver : WindowsDriver; console.log(`Platform: ${process.platform} — using ${IS_LINUX ? "LibreOffice/Linux" : "PowerPoint/Windows"} driver`); // ─── WebSocket server ───────────────────────────────────────────────────────── const clients = new Set>(); function broadcast(payload: object) { const msg = JSON.stringify(payload); for (const ws of clients) ws.send(msg); } async function handleCmd(cmd: string): Promise { const navCmds = ["next", "prev", "start", "end", "status"] as const; type NavCmd = typeof navCmds[number]; if (!(navCmds as readonly string[]).includes(cmd)) { broadcast({ event: "error", message: `Unknown command: ${cmd}` }); return; } console.log(`[cmd] executing: ${cmd}`); const { current, total } = await driver[cmd as NavCmd](); console.log(`[cmd] result: current=${current} total=${total}`); broadcast({ event: "slide", current, total }); // Request image: use current if known, fall back to 1 for navigation cmds // where slideshow may be active but status returned 0 const imageIndex = current > 0 ? current : (cmd !== "end" && total > 0 ? 1 : 0); if (imageIndex > 0) { console.log(`[cmd] requesting slide image for index=${imageIndex}`); const b64 = await driver.slideImage(imageIndex); if (b64) { console.log(`[cmd] broadcasting image, base64 length=${b64.length}`); broadcast({ event: "image", data: b64 }); } else { console.warn(`[cmd] slideImage returned empty for index=${imageIndex}`); } } } const server = Bun.serve({ port: PORT, fetch(req, server) { // Linux file picker routes if (IS_LINUX && req.url.includes("/picker")) { return servePickerRequest(req).then(r => r ?? new Response("Not found", { status: 404 })); } if (!req.headers.get("upgrade")) { const pickerNote = IS_LINUX ? `\nFile picker: http://:${PORT}/picker` : ""; return new Response( `PPT Remote Server — ${IS_LINUX ? "LibreOffice" : "PowerPoint"} mode\n` + `WebSocket: ws://:${PORT}` + pickerNote, { headers: { "Content-Type": "text/plain" } } ); } const ok = server.upgrade(req); return ok ? undefined : new Response("WebSocket upgrade failed", { status: 500 }); }, websocket: { open(ws) { clients.add(ws); console.log(`Client connected (${clients.size} total)`); driver.status().then(async ({ current, total }) => { ws.send(JSON.stringify({ event: "slide", current, total })); if (current > 0) { const b64 = await driver.slideImage(current); if (b64) ws.send(JSON.stringify({ event: "image", data: b64 })); } }); }, async message(ws, message) { let cmd: string; try { ({ cmd } = JSON.parse(message as string)); } catch { ws.send(JSON.stringify({ event: "error", message: "Invalid JSON" })); return; } try { await handleCmd(cmd); } catch (err) { console.error("[cmd error]", err); ws.send(JSON.stringify({ event: "error", message: String(err) })); } }, close(ws) { clients.delete(ws); console.log(`Client disconnected (${clients.size} total)`); }, }, }); console.log(`PPT Remote Server listening on ws://0.0.0.0:${PORT}`); // On Linux, prompt to open a presentation immediately if (IS_LINUX) promptAndLaunch(); else console.log("Make sure PowerPoint is open with a presentation before connecting.");