| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- /**
- * 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": "<base64 jpeg>" }
- * { "event": "error", "message": "..." }
- */
- const PORT = 8765;
- const IS_LINUX = process.platform === "linux";
- // ─── Helpers ──────────────────────────────────────────────────────────────────
- async function run(cmd: string, args: string[]): Promise<string> {
- 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<string>; // 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<string> {
- // 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<string> {
- 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<import("bun").ServerWebSocket<unknown>>();
- function broadcast(payload: object) {
- const msg = JSON.stringify(payload);
- for (const ws of clients) ws.send(msg);
- }
- async function handleCmd(cmd: string): Promise<void> {
- 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://<host>:${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}`);
|