index.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  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. return run("python3", ["-c", script]);
  126. }
  127. /** Returns the window ID of the LibreOffice Impress window */
  128. async function libreofficeWinId(): Promise<string> {
  129. return run("xdotool", ["search", "--name", "LibreOffice Impress"]);
  130. }
  131. const LinuxDriver: Driver = {
  132. async next() {
  133. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  134. await run("xdotool", ["key", "--window", wid, "Right"]);
  135. await Bun.sleep(300);
  136. return LinuxDriver.status();
  137. },
  138. async prev() {
  139. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  140. await run("xdotool", ["key", "--window", wid, "Left"]);
  141. await Bun.sleep(300);
  142. return LinuxDriver.status();
  143. },
  144. async start() {
  145. // F5 starts the slideshow from the beginning; Shift+F5 from current slide
  146. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  147. await run("xdotool", ["key", "--window", wid, "shift+F5"]);
  148. await Bun.sleep(1000);
  149. return LinuxDriver.status();
  150. },
  151. async end() {
  152. const wid = (await libreofficeWinId()).split("\n")[0].trim();
  153. await run("xdotool", ["key", "--window", wid, "Escape"]);
  154. await Bun.sleep(300);
  155. const { total } = await LinuxDriver.status();
  156. return { current: 0, total };
  157. },
  158. async status() {
  159. console.log("[linux] status: running UNO query");
  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. current = 0
  176. try:
  177. controller = comp.getCurrentController()
  178. # During slideshow: PresentationController has getCurrentSlideIndex()
  179. if hasattr(controller, 'getCurrentSlideIndex'):
  180. current = controller.getCurrentSlideIndex() + 1
  181. print(f"[status] slideshow mode, index={current}", file=sys.stderr)
  182. # Editor mode: DrawController has getCurrentPage()
  183. elif hasattr(controller, 'getCurrentPage'):
  184. page = controller.getCurrentPage()
  185. # page.PageIndex may not exist on all objects; use DrawPages index
  186. for i in range(draw.Count):
  187. if draw.getByIndex(i) == page:
  188. current = i + 1
  189. break
  190. print(f"[status] editor mode, index={current}", file=sys.stderr)
  191. else:
  192. print(f"[status] unknown controller type: {type(controller)}", file=sys.stderr)
  193. except Exception as inner:
  194. print(f"[status] could not get current page: {inner}", file=sys.stderr)
  195. current = 0
  196. print(f"{current}/{total}")
  197. except Exception as e:
  198. print(f"[status] UNO error: {e}", file=sys.stderr)
  199. print("0/0")
  200. `);
  201. console.log(`[linux] status raw output: "${raw}"`);
  202. const result = parseSlide(raw);
  203. console.log(`[linux] status parsed: current=${result.current} total=${result.total}`);
  204. return result;
  205. },
  206. async slideImage(index: number) {
  207. console.log(`[linux] slideImage: exporting slide index=${index}`);
  208. const raw = await pyUno(`
  209. import sys, os, base64, tempfile
  210. try:
  211. import uno
  212. from com.sun.star.beans import PropertyValue
  213. localCtx = uno.getComponentContext()
  214. resolver = localCtx.ServiceManager.createInstanceWithContext(
  215. "com.sun.star.bridge.UnoUrlResolver", localCtx)
  216. ctx = resolver.resolve(
  217. "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
  218. smgr = ctx.ServiceManager
  219. desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
  220. comp = desktop.getCurrentComponent()
  221. draw = comp.DrawPages
  222. print(f"[slideImage] total slides: {draw.Count}", file=sys.stderr)
  223. slide = draw.getByIndex(${index - 1})
  224. print(f"[slideImage] got slide: {slide.Name}", file=sys.stderr)
  225. tmp = tempfile.mktemp(suffix='.png')
  226. print(f"[slideImage] exporting to: {tmp}", file=sys.stderr)
  227. graphicExporter = smgr.createInstanceWithContext(
  228. "com.sun.star.drawing.GraphicExportFilter", ctx)
  229. # setSourceDocument must be called with the slide (DrawPage)
  230. graphicExporter.setSourceDocument(slide)
  231. def mkprop(name, val):
  232. p = PropertyValue()
  233. p.Name = name
  234. p.Value = val
  235. return p
  236. # Do NOT pass Selection — setSourceDocument is sufficient
  237. props = (
  238. mkprop("URL", uno.systemPathToFileUrl(tmp)),
  239. mkprop("MediaType", "image/png"),
  240. mkprop("PixelWidth", 800),
  241. mkprop("PixelHeight", 600),
  242. )
  243. graphicExporter.filter(props)
  244. if os.path.exists(tmp):
  245. size = os.path.getsize(tmp)
  246. print(f"[slideImage] exported file size: {size} bytes", file=sys.stderr)
  247. with open(tmp, 'rb') as f:
  248. data = f.read()
  249. b64 = base64.b64encode(data).decode()
  250. print(f"[slideImage] base64 length: {len(b64)}", file=sys.stderr)
  251. print(b64)
  252. os.unlink(tmp)
  253. else:
  254. print(f"[slideImage] export file not created at {tmp}", file=sys.stderr)
  255. print("")
  256. except Exception as e:
  257. import traceback
  258. print(f"[slideImage] exception: {e}", file=sys.stderr)
  259. traceback.print_exc(file=sys.stderr)
  260. print("")
  261. `);
  262. const trimmed = raw.trim();
  263. console.log(`[linux] slideImage: got output length=${trimmed.length}${trimmed.length === 0 ? " (EMPTY — check stderr above)" : ""}`);
  264. return trimmed;
  265. },
  266. };
  267. // ─── Linux: LibreOffice process manager ──────────────────────────────────────
  268. // Serves a web file picker at http://<host>:PORT/picker.
  269. // When a file is selected it POSTs to /picker/open, which launches LibreOffice.
  270. // Re-prompts (via broadcast) when LibreOffice exits.
  271. const UNO_PORT = 2002;
  272. let libreofficeProc: ReturnType<typeof Bun.spawn> | null = null;
  273. // Resolves when the user picks a file via the web UI
  274. let pickerResolve: ((path: string) => void) | null = null;
  275. function waitForFilePick(): Promise<string> {
  276. return new Promise((resolve) => {
  277. pickerResolve = resolve;
  278. });
  279. }
  280. const PICKER_HTML = `<!DOCTYPE html>
  281. <html lang="en">
  282. <head>
  283. <meta charset="UTF-8">
  284. <meta name="viewport" content="width=device-width, initial-scale=1">
  285. <title>Open Presentation — PPT Remote</title>
  286. <style>
  287. * { box-sizing: border-box; margin: 0; padding: 0; }
  288. body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; }
  289. header { background: #16213e; padding: 12px 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
  290. header h1 { font-size: 1rem; font-weight: 600; flex: 1; }
  291. header button { background: #0f3460; border: none; color: #e0e0e0; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 0.85rem; display: flex; align-items: center; gap: 5px; }
  292. header button:hover { background: #e94560; }
  293. #breadcrumb { padding: 8px 16px; background: #16213e; font-size: 0.82rem; color: #888; border-bottom: 1px solid #0f3460; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; flex-shrink: 0; }
  294. #breadcrumb .crumb { cursor: pointer; color: #e94560; } #breadcrumb .crumb:hover { text-decoration: underline; }
  295. #breadcrumb .sep { color: #444; }
  296. /* Drop zone */
  297. #dropzone { margin: 12px 16px; border: 2px dashed #0f3460; border-radius: 12px; padding: 20px; text-align: center; color: #555; font-size: 0.9rem; transition: border-color 0.2s, background 0.2s; flex-shrink: 0; }
  298. #dropzone.drag-over { border-color: #e94560; background: rgba(233,69,96,0.08); color: #e0e0e0; }
  299. #dropzone span { font-size: 1.6rem; display: block; margin-bottom: 6px; }
  300. #list { padding: 8px 16px 80px; flex: 1; overflow-y: auto; }
  301. .entry { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s; user-select: none; }
  302. .entry:hover { background: #0f3460; }
  303. .entry .icon { font-size: 1.2rem; width: 26px; text-align: center; flex-shrink: 0; }
  304. .entry .name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  305. .entry .name.file { color: #a8d8ea; }
  306. #status { position: fixed; bottom: 0; left: 0; right: 0; background: #0f3460; padding: 12px 20px; font-size: 0.9rem; display: none; border-top: 1px solid #e94560; }
  307. #status.show { display: block; }
  308. .loading { text-align: center; padding: 40px; color: #555; }
  309. </style>
  310. </head>
  311. <body>
  312. <header>
  313. <span style="font-size:1.4rem">📂</span>
  314. <h1>Select a Presentation — PPT Remote</h1>
  315. <button onclick="browse(cwd)" title="Reload current folder">🔄 Reload</button>
  316. <button onclick="browse(HOME)" title="Go to home directory">🏠 Home</button>
  317. </header>
  318. <div id="breadcrumb"></div>
  319. <div id="dropzone" id="dropzone">
  320. <span>⬆️</span>
  321. Drag &amp; drop a presentation file here to open it
  322. </div>
  323. <div id="list"><div class="loading">Loading…</div></div>
  324. <div id="status"></div>
  325. <script>
  326. const HOME = '__HOME__';
  327. let cwd = HOME;
  328. // ── Drag & drop ──────────────────────────────────────────────────────────
  329. const dz = document.getElementById('dropzone');
  330. document.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); });
  331. document.addEventListener('dragleave', e => { if (e.relatedTarget === null) dz.classList.remove('drag-over'); });
  332. document.addEventListener('drop', async e => {
  333. e.preventDefault();
  334. dz.classList.remove('drag-over');
  335. const files = e.dataTransfer.files;
  336. if (!files.length) return;
  337. // DataTransfer gives us File objects but not full paths in browsers.
  338. // We use the webkitRelativePath or name and resolve against cwd.
  339. const file = files[0];
  340. // Try to get the full path via the non-standard .path property (Electron/some browsers)
  341. const fullPath = file.path || (cwd.replace(/\\/$/, '') + '/' + file.name);
  342. await openFile(fullPath);
  343. });
  344. // ── Browsing ─────────────────────────────────────────────────────────────
  345. async function browse(path) {
  346. cwd = path;
  347. document.getElementById('list').innerHTML = '<div class="loading">Loading…</div>';
  348. const res = await fetch('/picker/ls?path=' + encodeURIComponent(path));
  349. const { entries, error } = await res.json();
  350. renderBreadcrumb(path);
  351. if (error) { document.getElementById('list').innerHTML = '<div class="loading">' + error + '</div>'; return; }
  352. renderList(entries);
  353. }
  354. function renderBreadcrumb(path) {
  355. const parts = path.split('/').filter(Boolean);
  356. let html = '<span class="crumb" onclick="browse(\\'/\\')">/ root</span>';
  357. let acc = '';
  358. for (const p of parts) {
  359. acc += '/' + p;
  360. const cur = acc;
  361. html += '<span class="sep">›</span><span class="crumb" onclick="browse(\\''+cur+'\\')">'+p+'</span>';
  362. }
  363. document.getElementById('breadcrumb').innerHTML = html;
  364. }
  365. function renderList(entries) {
  366. if (!entries.length) { document.getElementById('list').innerHTML = '<div class="loading">Empty folder</div>'; return; }
  367. const el = document.getElementById('list');
  368. el.innerHTML = '';
  369. if (cwd !== '/') {
  370. const up = document.createElement('div');
  371. up.className = 'entry';
  372. up.innerHTML = '<span class="icon">⬆️</span><span class="name">..</span>';
  373. up.onclick = () => browse(cwd.split('/').slice(0,-1).join('/') || '/');
  374. el.appendChild(up);
  375. }
  376. for (const e of entries) {
  377. const div = document.createElement('div');
  378. div.className = 'entry';
  379. div.innerHTML = \`<span class="icon">\${e.dir ? '📁' : '📄'}</span><span class="name \${e.dir ? '' : 'file'}">\${e.name}</span>\`;
  380. if (e.dir) div.onclick = () => browse(e.path);
  381. else div.onclick = () => openFile(e.path);
  382. el.appendChild(div);
  383. }
  384. }
  385. // ── Open file ─────────────────────────────────────────────────────────────
  386. async function openFile(path) {
  387. const st = document.getElementById('status');
  388. st.textContent = 'Opening: ' + path + ' …';
  389. st.className = 'show';
  390. const res = await fetch('/picker/open', {
  391. method: 'POST',
  392. headers: { 'Content-Type': 'application/json' },
  393. body: JSON.stringify({ path })
  394. });
  395. const data = await res.json();
  396. if (data.ok) {
  397. st.textContent = '✅ Opened! You can close this tab.';
  398. } else {
  399. st.textContent = '❌ Error: ' + data.error;
  400. }
  401. }
  402. browse(cwd);
  403. </script>
  404. </body>
  405. </html>`;
  406. async function servePickerRequest(req: Request): Promise<Response | null> {
  407. const url = new URL(req.url);
  408. if (url.pathname === "/picker") {
  409. const home = process.env.HOME || "/root";
  410. const html = PICKER_HTML.replace("'__HOME__'", JSON.stringify(home));
  411. return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
  412. }
  413. if (url.pathname === "/picker/ls") {
  414. const home = process.env.HOME || "/root";
  415. const dirPath = url.searchParams.get("path") || home;
  416. try {
  417. const entries: { name: string; path: string; dir: boolean }[] = [];
  418. const dir = await import("node:fs/promises");
  419. const items = await dir.readdir(dirPath, { withFileTypes: true });
  420. for (const entry of items) {
  421. if (entry.name.startsWith(".")) continue;
  422. entries.push({
  423. name: entry.name,
  424. path: `${dirPath.replace(/\/$/, "")}/${entry.name}`,
  425. dir: entry.isDirectory(),
  426. });
  427. }
  428. // dirs first, then files, both alphabetical
  429. entries.sort((a, b) => {
  430. if (a.dir !== b.dir) return a.dir ? -1 : 1;
  431. return a.name.localeCompare(b.name);
  432. });
  433. return Response.json({ entries });
  434. } catch (e) {
  435. return Response.json({ entries: [], error: String(e) });
  436. }
  437. }
  438. if (url.pathname === "/picker/open" && req.method === "POST") {
  439. const { path } = await req.json() as { path: string };
  440. if (!path) return Response.json({ ok: false, error: "No path provided" });
  441. if (pickerResolve) {
  442. pickerResolve(path);
  443. pickerResolve = null;
  444. return Response.json({ ok: true });
  445. }
  446. // No active picker waiting — launch directly (e.g. re-open after close)
  447. launchLibreOffice(path).catch(console.error);
  448. return Response.json({ ok: true });
  449. }
  450. return null; // not a picker route
  451. }
  452. async function launchLibreOffice(filePath: string): Promise<void> {
  453. console.log(`Opening: ${filePath}`);
  454. libreofficeProc = Bun.spawn([
  455. "libreoffice",
  456. `--accept=socket,host=localhost,port=${UNO_PORT};urp;StarOffice.ServiceManager`,
  457. "--impress",
  458. filePath,
  459. ], { stdout: "inherit", stderr: "inherit" });
  460. // Wait for UNO socket to become available (up to 10s)
  461. for (let i = 0; i < 20; i++) {
  462. await Bun.sleep(500);
  463. try {
  464. const status = await LinuxDriver.status();
  465. if (status.total > 0) break;
  466. } catch { /* not ready yet */ }
  467. }
  468. broadcast({ event: "presentation", status: "opened", file: filePath.split("/").pop() });
  469. // Watch for process exit, then re-prompt
  470. libreofficeProc.exited.then(async () => {
  471. console.log("LibreOffice closed.");
  472. broadcast({ event: "presentation", status: "closed" });
  473. libreofficeProc = null;
  474. await promptAndLaunch();
  475. });
  476. }
  477. async function promptAndLaunch(): Promise<void> {
  478. console.log(`Open a presentation at http://localhost:${PORT}/picker`);
  479. broadcast({ event: "presentation", status: "none", pickerUrl: `/picker` });
  480. const file = await waitForFilePick();
  481. await launchLibreOffice(file);
  482. }
  483. // ─── Select driver based on platform ─────────────────────────────────────────
  484. const driver: Driver = IS_LINUX ? LinuxDriver : WindowsDriver;
  485. console.log(`Platform: ${process.platform} — using ${IS_LINUX ? "LibreOffice/Linux" : "PowerPoint/Windows"} driver`);
  486. // ─── WebSocket server ─────────────────────────────────────────────────────────
  487. const clients = new Set<import("bun").ServerWebSocket<unknown>>();
  488. function broadcast(payload: object) {
  489. const msg = JSON.stringify(payload);
  490. for (const ws of clients) ws.send(msg);
  491. }
  492. async function handleCmd(cmd: string): Promise<void> {
  493. const navCmds = ["next", "prev", "start", "end", "status"] as const;
  494. type NavCmd = typeof navCmds[number];
  495. if (!(navCmds as readonly string[]).includes(cmd)) {
  496. broadcast({ event: "error", message: `Unknown command: ${cmd}` });
  497. return;
  498. }
  499. console.log(`[cmd] executing: ${cmd}`);
  500. const { current, total } = await driver[cmd as NavCmd]();
  501. console.log(`[cmd] result: current=${current} total=${total}`);
  502. broadcast({ event: "slide", current, total });
  503. // Request image: use current if known, fall back to 1 for navigation cmds
  504. // where slideshow may be active but status returned 0
  505. const imageIndex = current > 0 ? current : (cmd !== "end" && total > 0 ? 1 : 0);
  506. if (imageIndex > 0) {
  507. console.log(`[cmd] requesting slide image for index=${imageIndex}`);
  508. const b64 = await driver.slideImage(imageIndex);
  509. if (b64) {
  510. console.log(`[cmd] broadcasting image, base64 length=${b64.length}`);
  511. broadcast({ event: "image", data: b64 });
  512. } else {
  513. console.warn(`[cmd] slideImage returned empty for index=${imageIndex}`);
  514. }
  515. }
  516. }
  517. const server = Bun.serve({
  518. port: PORT,
  519. fetch(req, server) {
  520. // Linux file picker routes
  521. if (IS_LINUX && req.url.includes("/picker")) {
  522. return servePickerRequest(req).then(r => r ?? new Response("Not found", { status: 404 }));
  523. }
  524. if (!req.headers.get("upgrade")) {
  525. const pickerNote = IS_LINUX ? `\nFile picker: http://<host>:${PORT}/picker` : "";
  526. return new Response(
  527. `PPT Remote Server — ${IS_LINUX ? "LibreOffice" : "PowerPoint"} mode\n` +
  528. `WebSocket: ws://<host>:${PORT}` + pickerNote,
  529. { headers: { "Content-Type": "text/plain" } }
  530. );
  531. }
  532. const ok = server.upgrade(req);
  533. return ok ? undefined : new Response("WebSocket upgrade failed", { status: 500 });
  534. },
  535. websocket: {
  536. open(ws) {
  537. clients.add(ws);
  538. console.log(`Client connected (${clients.size} total)`);
  539. driver.status().then(async ({ current, total }) => {
  540. ws.send(JSON.stringify({ event: "slide", current, total }));
  541. if (current > 0) {
  542. const b64 = await driver.slideImage(current);
  543. if (b64) ws.send(JSON.stringify({ event: "image", data: b64 }));
  544. }
  545. });
  546. },
  547. async message(ws, message) {
  548. let cmd: string;
  549. try {
  550. ({ cmd } = JSON.parse(message as string));
  551. } catch {
  552. ws.send(JSON.stringify({ event: "error", message: "Invalid JSON" }));
  553. return;
  554. }
  555. try {
  556. await handleCmd(cmd);
  557. } catch (err) {
  558. console.error("[cmd error]", err);
  559. ws.send(JSON.stringify({ event: "error", message: String(err) }));
  560. }
  561. },
  562. close(ws) {
  563. clients.delete(ws);
  564. console.log(`Client disconnected (${clients.size} total)`);
  565. },
  566. },
  567. });
  568. console.log(`PPT Remote Server listening on ws://0.0.0.0:${PORT}`);
  569. // On Linux, prompt to open a presentation immediately
  570. if (IS_LINUX) promptAndLaunch();
  571. else console.log("Make sure PowerPoint is open with a presentation before connecting.");