/** * 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 { // Inline python passed via -c; single-quotes escaped for shell 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"]); } const LinuxDriver: Driver = { async next() { const wid = (await libreofficeWinId()).split("\n")[0].trim(); await run("xdotool", ["key", "--window", wid, "Right"]); await Bun.sleep(300); return LinuxDriver.status(); }, async prev() { const wid = (await libreofficeWinId()).split("\n")[0].trim(); await run("xdotool", ["key", "--window", wid, "Left"]); await Bun.sleep(300); return LinuxDriver.status(); }, async start() { // F5 starts the slideshow from the beginning; Shift+F5 from current slide const wid = (await libreofficeWinId()).split("\n")[0].trim(); await run("xdotool", ["key", "--window", wid, "shift+F5"]); await Bun.sleep(1000); return LinuxDriver.status(); }, async end() { const wid = (await libreofficeWinId()).split("\n")[0].trim(); await run("xdotool", ["key", "--window", wid, "Escape"]); await Bun.sleep(300); const { total } = await LinuxDriver.status(); return { current: 0, total }; }, async status() { 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 try: controller = comp.getCurrentController() current = controller.getCurrentPage().PageIndex + 1 except: current = 0 print(f"{current}/{total}") except Exception as e: print(f"0/0", file=sys.stderr) print("0/0") `); return parseSlide(raw); }, async slideImage(index: number) { // Export the slide via UNO as PNG, return base64 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 slide = draw.getByIndex(${index - 1}) tmp = tempfile.mktemp(suffix='.png') graphicExporter = smgr.createInstanceWithContext( "com.sun.star.drawing.GraphicExportFilter", ctx) graphicExporter.setSourceDocument(slide) props = [] def mkprop(name, val): p = PropertyValue() p.Name = name p.Value = val return p props.append(mkprop("URL", uno.systemPathToFileUrl(tmp))) props.append(mkprop("MediaType", "image/png")) props.append(mkprop("Selection", slide)) graphicExporter.filter(tuple(props)) with open(tmp, 'rb') as f: print(base64.b64encode(f.read()).decode()) os.unlink(tmp) except Exception as e: print("", file=sys.stdout) print(str(e), file=sys.stderr) `); return raw; }, }; // ─── 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; } const { current, total } = await driver[cmd as NavCmd](); broadcast({ event: "slide", current, total }); if (current > 0 && cmd !== "end") { const b64 = await driver.slideImage(current); if (b64) broadcast({ event: "image", data: b64 }); } } const server = Bun.serve({ port: PORT, fetch(req, server) { if (!req.headers.get("upgrade")) { return new Response( `PPT Remote Server — ${IS_LINUX ? "LibreOffice" : "PowerPoint"} mode\n` + `WebSocket: ws://:${PORT}`, { 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}`);