index.ts 24 KB

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