index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. /**
  2. * PPT Remote Server
  3. * Controls PowerPoint (Windows) or LibreOffice Impress (Linux) via WebSocket.
  4. *
  5. * Commands (client → server):
  6. * { "cmd": "next" }
  7. * { "cmd": "prev" }
  8. * { "cmd": "start" } — start slideshow
  9. * { "cmd": "end" } — end slideshow
  10. * { "cmd": "status" } — request current slide info
  11. *
  12. * Events (server → client):
  13. * { "event": "slide", "current": 3, "total": 10 }
  14. * { "event": "image", "data": "<base64 jpeg>" }
  15. * { "event": "error", "message": "..." }
  16. */
  17. const PORT = 8765;
  18. const IS_LINUX = process.platform === "linux";
  19. // ─── Helpers ──────────────────────────────────────────────────────────────────
  20. async function run(cmd: string, args: string[]): Promise<string> {
  21. const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe" });
  22. const [out, err] = await Promise.all([
  23. new Response(proc.stdout).text(),
  24. new Response(proc.stderr).text(),
  25. ]);
  26. await proc.exited;
  27. if (err.trim()) console.error(`[stderr]`, err.trim());
  28. return out.trim();
  29. }
  30. function parseSlide(raw: string): { current: number; total: number } {
  31. const [c, t] = raw.split("/").map(Number);
  32. return { current: c || 0, total: t || 0 };
  33. }
  34. // ─── Driver interface ─────────────────────────────────────────────────────────
  35. interface Driver {
  36. next(): Promise<{ current: number; total: number }>;
  37. prev(): Promise<{ current: number; total: number }>;
  38. start(): Promise<{ current: number; total: number }>;
  39. end(): Promise<{ current: number; total: number }>;
  40. status(): Promise<{ current: number; total: number }>;
  41. slideImage(index: number): Promise<string>; // base64 jpeg/png
  42. }
  43. // ─── Windows Driver (PowerShell + COM) ───────────────────────────────────────
  44. function ps(script: string) {
  45. return run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
  46. }
  47. const WindowsDriver: Driver = {
  48. async next() {
  49. const raw = await ps(`
  50. $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
  51. $app.ActivePresentation.SlideShowWindow.View.Next()
  52. Start-Sleep -Milliseconds 300
  53. $v = $app.ActivePresentation.SlideShowWindow.View
  54. "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)"
  55. `);
  56. return parseSlide(raw);
  57. },
  58. async prev() {
  59. const raw = await ps(`
  60. $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
  61. $app.ActivePresentation.SlideShowWindow.View.Previous()
  62. Start-Sleep -Milliseconds 300
  63. $v = $app.ActivePresentation.SlideShowWindow.View
  64. "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)"
  65. `);
  66. return parseSlide(raw);
  67. },
  68. async start() {
  69. const raw = await ps(`
  70. $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
  71. $app.ActivePresentation.SlideShowSettings.Run() | Out-Null
  72. Start-Sleep -Milliseconds 800
  73. $v = $app.ActivePresentation.SlideShowWindow.View
  74. "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)"
  75. `);
  76. return parseSlide(raw);
  77. },
  78. async end() {
  79. const raw = await ps(`
  80. $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
  81. $app.ActivePresentation.SlideShowWindow.View.Exit()
  82. "0/$($app.ActivePresentation.Slides.Count)"
  83. `);
  84. return parseSlide(raw);
  85. },
  86. async status() {
  87. const raw = await ps(`
  88. try {
  89. $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
  90. $total = $app.ActivePresentation.Slides.Count
  91. try {
  92. $v = $app.ActivePresentation.SlideShowWindow.View
  93. "$($v.CurrentShowPosition)/$total"
  94. } catch { "0/$total" }
  95. } catch { "0/0" }
  96. `);
  97. return parseSlide(raw);
  98. },
  99. async slideImage(index: number) {
  100. return ps(`
  101. try {
  102. $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
  103. $slide = $app.ActivePresentation.Slides(${index})
  104. $tmp = [System.IO.Path]::GetTempFileName() -replace '\\.tmp$','.jpg'
  105. $slide.Export($tmp, 'JPG', 800, 600)
  106. $bytes = [System.IO.File]::ReadAllBytes($tmp)
  107. [System.Convert]::ToBase64String($bytes)
  108. Remove-Item $tmp -ErrorAction SilentlyContinue
  109. } catch { Write-Error $_.Exception.Message; "" }
  110. `);
  111. },
  112. };
  113. // ─── Linux Driver (Python UNO + xdotool) ─────────────────────────────────────
  114. //
  115. // Prerequisites:
  116. // - LibreOffice Impress started with UNO socket listener:
  117. // libreoffice --impress --accept="socket,host=localhost,port=2002;urp;StarOffice.ServiceManager"
  118. // - python3 with uno module (ships with LibreOffice)
  119. // - xdotool (sudo apt install xdotool)
  120. //
  121. // The Python helper talks to LibreOffice over UNO to read slide state.
  122. // Navigation uses xdotool to send keys to the Impress window (most reliable
  123. // method that works with both windowed and fullscreen slideshow modes).
  124. function pyUno(script: string): Promise<string> {
  125. // Inline python passed via -c; single-quotes escaped for shell
  126. return run("python3", ["-c", script]);
  127. }
  128. /** Returns the window ID of the LibreOffice Impress window */
  129. async function libreofficeWinId(): Promise<string> {
  130. return run("xdotool", ["search", "--name", "LibreOffice Impress"]);
  131. }
  132. const LinuxDriver: Driver = {
  133. async next() {
  134. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  135. await run("xdotool", ["key", "--window", wid, "Right"]);
  136. await Bun.sleep(300);
  137. return LinuxDriver.status();
  138. },
  139. async prev() {
  140. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  141. await run("xdotool", ["key", "--window", wid, "Left"]);
  142. await Bun.sleep(300);
  143. return LinuxDriver.status();
  144. },
  145. async start() {
  146. // F5 starts the slideshow from the beginning; Shift+F5 from current slide
  147. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  148. await run("xdotool", ["key", "--window", wid, "shift+F5"]);
  149. await Bun.sleep(1000);
  150. return LinuxDriver.status();
  151. },
  152. async end() {
  153. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  154. await run("xdotool", ["key", "--window", wid, "Escape"]);
  155. await Bun.sleep(300);
  156. const { total } = await LinuxDriver.status();
  157. return { current: 0, total };
  158. },
  159. async status() {
  160. const raw = await pyUno(`
  161. import sys
  162. try:
  163. import uno
  164. from com.sun.star.beans import PropertyValue
  165. localCtx = uno.getComponentContext()
  166. resolver = localCtx.ServiceManager.createInstanceWithContext(
  167. "com.sun.star.bridge.UnoUrlResolver", localCtx)
  168. ctx = resolver.resolve(
  169. "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
  170. smgr = ctx.ServiceManager
  171. desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
  172. comp = desktop.getCurrentComponent()
  173. draw = comp.DrawPages
  174. total = draw.Count
  175. try:
  176. controller = comp.getCurrentController()
  177. current = controller.getCurrentPage().PageIndex + 1
  178. except:
  179. current = 0
  180. print(f"{current}/{total}")
  181. except Exception as e:
  182. print(f"0/0", file=sys.stderr)
  183. print("0/0")
  184. `);
  185. return parseSlide(raw);
  186. },
  187. async slideImage(index: number) {
  188. // Export the slide via UNO as PNG, return base64
  189. const raw = await pyUno(`
  190. import sys, os, base64, tempfile
  191. try:
  192. import uno
  193. from com.sun.star.beans import PropertyValue
  194. localCtx = uno.getComponentContext()
  195. resolver = localCtx.ServiceManager.createInstanceWithContext(
  196. "com.sun.star.bridge.UnoUrlResolver", localCtx)
  197. ctx = resolver.resolve(
  198. "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
  199. smgr = ctx.ServiceManager
  200. desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
  201. comp = desktop.getCurrentComponent()
  202. draw = comp.DrawPages
  203. slide = draw.getByIndex(${index - 1})
  204. tmp = tempfile.mktemp(suffix='.png')
  205. graphicExporter = smgr.createInstanceWithContext(
  206. "com.sun.star.drawing.GraphicExportFilter", ctx)
  207. graphicExporter.setSourceDocument(slide)
  208. props = []
  209. def mkprop(name, val):
  210. p = PropertyValue()
  211. p.Name = name
  212. p.Value = val
  213. return p
  214. props.append(mkprop("URL", uno.systemPathToFileUrl(tmp)))
  215. props.append(mkprop("MediaType", "image/png"))
  216. props.append(mkprop("Selection", slide))
  217. graphicExporter.filter(tuple(props))
  218. with open(tmp, 'rb') as f:
  219. print(base64.b64encode(f.read()).decode())
  220. os.unlink(tmp)
  221. except Exception as e:
  222. print("", file=sys.stdout)
  223. print(str(e), file=sys.stderr)
  224. `);
  225. return raw;
  226. },
  227. };
  228. // ─── Select driver based on platform ─────────────────────────────────────────
  229. const driver: Driver = IS_LINUX ? LinuxDriver : WindowsDriver;
  230. console.log(`Platform: ${process.platform} — using ${IS_LINUX ? "LibreOffice/Linux" : "PowerPoint/Windows"} driver`);
  231. // ─── WebSocket server ─────────────────────────────────────────────────────────
  232. const clients = new Set<import("bun").ServerWebSocket<unknown>>();
  233. function broadcast(payload: object) {
  234. const msg = JSON.stringify(payload);
  235. for (const ws of clients) ws.send(msg);
  236. }
  237. async function handleCmd(cmd: string): Promise<void> {
  238. const navCmds = ["next", "prev", "start", "end", "status"] as const;
  239. type NavCmd = typeof navCmds[number];
  240. if (!(navCmds as readonly string[]).includes(cmd)) {
  241. broadcast({ event: "error", message: `Unknown command: ${cmd}` });
  242. return;
  243. }
  244. const { current, total } = await driver[cmd as NavCmd]();
  245. broadcast({ event: "slide", current, total });
  246. if (current > 0 && cmd !== "end") {
  247. const b64 = await driver.slideImage(current);
  248. if (b64) broadcast({ event: "image", data: b64 });
  249. }
  250. }
  251. const server = Bun.serve({
  252. port: PORT,
  253. fetch(req, server) {
  254. if (!req.headers.get("upgrade")) {
  255. return new Response(
  256. `PPT Remote Server — ${IS_LINUX ? "LibreOffice" : "PowerPoint"} mode\n` +
  257. `WebSocket: ws://<host>:${PORT}`,
  258. { headers: { "Content-Type": "text/plain" } }
  259. );
  260. }
  261. const ok = server.upgrade(req);
  262. return ok ? undefined : new Response("WebSocket upgrade failed", { status: 500 });
  263. },
  264. websocket: {
  265. open(ws) {
  266. clients.add(ws);
  267. console.log(`Client connected (${clients.size} total)`);
  268. driver.status().then(async ({ current, total }) => {
  269. ws.send(JSON.stringify({ event: "slide", current, total }));
  270. if (current > 0) {
  271. const b64 = await driver.slideImage(current);
  272. if (b64) ws.send(JSON.stringify({ event: "image", data: b64 }));
  273. }
  274. });
  275. },
  276. async message(ws, message) {
  277. let cmd: string;
  278. try {
  279. ({ cmd } = JSON.parse(message as string));
  280. } catch {
  281. ws.send(JSON.stringify({ event: "error", message: "Invalid JSON" }));
  282. return;
  283. }
  284. try {
  285. await handleCmd(cmd);
  286. } catch (err) {
  287. console.error("[cmd error]", err);
  288. ws.send(JSON.stringify({ event: "error", message: String(err) }));
  289. }
  290. },
  291. close(ws) {
  292. clients.delete(ws);
  293. console.log(`Client disconnected (${clients.size} total)`);
  294. },
  295. },
  296. });
  297. console.log(`PPT Remote Server listening on ws://0.0.0.0:${PORT}`);