index.ts 30 KB

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