| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- /**
- * PPT Remote Server
- * Controls PowerPoint (Windows) or LibreOffice Impress (Linux) via WebSocket.
- *
- * Commands (client → server):
- * { "cmd": "next" }
- * { "cmd": "prev" }
- * { "cmd": "start" } — start slideshow
- * { "cmd": "end" } — end slideshow
- * { "cmd": "status" } — request current slide info
- *
- * Events (server → client):
- * { "event": "slide", "current": 3, "total": 10 }
- * { "event": "image", "data": "<base64 jpeg>" }
- * { "event": "error", "message": "..." }
- */
- const PORT = 8765;
- const IS_LINUX = process.platform === "linux";
- // ─── Helpers ──────────────────────────────────────────────────────────────────
- async function run(cmd: string, args: string[]): Promise<string> {
- const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe" });
- const [out, err] = await Promise.all([
- new Response(proc.stdout).text(),
- new Response(proc.stderr).text(),
- ]);
- await proc.exited;
- if (err.trim()) console.error(`[stderr]`, err.trim());
- return out.trim();
- }
- function parseSlide(raw: string): { current: number; total: number } {
- const [c, t] = raw.split("/").map(Number);
- return { current: c || 0, total: t || 0 };
- }
- // ─── Driver interface ─────────────────────────────────────────────────────────
- interface Driver {
- next(): Promise<{ current: number; total: number }>;
- prev(): Promise<{ current: number; total: number }>;
- start(): Promise<{ current: number; total: number }>;
- end(): Promise<{ current: number; total: number }>;
- status(): Promise<{ current: number; total: number }>;
- slideImage(index: number): Promise<string>; // base64 jpeg/png
- }
- // ─── Windows Driver (PowerShell + COM) ───────────────────────────────────────
- function ps(script: string) {
- return run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
- }
- const WindowsDriver: Driver = {
- async next() {
- const raw = await ps(`
- $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
- $app.ActivePresentation.SlideShowWindow.View.Next()
- Start-Sleep -Milliseconds 300
- $v = $app.ActivePresentation.SlideShowWindow.View
- "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)"
- `);
- return parseSlide(raw);
- },
- async prev() {
- const raw = await ps(`
- $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
- $app.ActivePresentation.SlideShowWindow.View.Previous()
- Start-Sleep -Milliseconds 300
- $v = $app.ActivePresentation.SlideShowWindow.View
- "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)"
- `);
- return parseSlide(raw);
- },
- async start() {
- const raw = await ps(`
- $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
- $app.ActivePresentation.SlideShowSettings.Run() | Out-Null
- Start-Sleep -Milliseconds 800
- $v = $app.ActivePresentation.SlideShowWindow.View
- "$($v.CurrentShowPosition)/$($app.ActivePresentation.Slides.Count)"
- `);
- return parseSlide(raw);
- },
- async end() {
- const raw = await ps(`
- $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
- $app.ActivePresentation.SlideShowWindow.View.Exit()
- "0/$($app.ActivePresentation.Slides.Count)"
- `);
- return parseSlide(raw);
- },
- async status() {
- const raw = await ps(`
- try {
- $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
- $total = $app.ActivePresentation.Slides.Count
- try {
- $v = $app.ActivePresentation.SlideShowWindow.View
- "$($v.CurrentShowPosition)/$total"
- } catch { "0/$total" }
- } catch { "0/0" }
- `);
- return parseSlide(raw);
- },
- async slideImage(index: number) {
- return ps(`
- try {
- $app = [Runtime.InteropServices.Marshal]::GetActiveObject('PowerPoint.Application')
- $slide = $app.ActivePresentation.Slides(${index})
- $tmp = [System.IO.Path]::GetTempFileName() -replace '\\.tmp$','.jpg'
- $slide.Export($tmp, 'JPG', 800, 600)
- $bytes = [System.IO.File]::ReadAllBytes($tmp)
- [System.Convert]::ToBase64String($bytes)
- Remove-Item $tmp -ErrorAction SilentlyContinue
- } catch { Write-Error $_.Exception.Message; "" }
- `);
- },
- };
- // ─── Linux Driver (Python UNO + xdotool) ─────────────────────────────────────
- //
- // Prerequisites:
- // - LibreOffice Impress started with UNO socket listener:
- // libreoffice --impress --accept="socket,host=localhost,port=2002;urp;StarOffice.ServiceManager"
- // - python3 with uno module (ships with LibreOffice)
- // - xdotool (sudo apt install xdotool)
- //
- // The Python helper talks to LibreOffice over UNO to read slide state.
- // Navigation uses xdotool to send keys to the Impress window (most reliable
- // method that works with both windowed and fullscreen slideshow modes).
- function pyUno(script: string): Promise<string> {
- return run("python3", ["-c", script]);
- }
- /** Returns the window ID of the LibreOffice Impress window */
- async function libreofficeWinId(): Promise<string> {
- return run("xdotool", ["search", "--name", "LibreOffice Impress"]);
- }
- const LinuxDriver: Driver = {
- async next() {
- const wid = (await libreofficeWinId()).split("\n")[0].trim();
- await run("xdotool", ["key", "--window", wid, "Right"]);
- await Bun.sleep(300);
- return LinuxDriver.status();
- },
- async prev() {
- const wid = (await libreofficeWinId()).split("\n")[0].trim();
- await run("xdotool", ["key", "--window", wid, "Left"]);
- await Bun.sleep(300);
- return LinuxDriver.status();
- },
- async start() {
- // F5 starts the slideshow from the beginning; Shift+F5 from current slide
- const wid = (await libreofficeWinId()).split("\n")[0].trim();
- await run("xdotool", ["key", "--window", wid, "shift+F5"]);
- await Bun.sleep(1000);
- return LinuxDriver.status();
- },
- async end() {
- const wid = (await libreofficeWinId()).split("\n")[0].trim();
- await run("xdotool", ["key", "--window", wid, "Escape"]);
- await Bun.sleep(300);
- const { total } = await LinuxDriver.status();
- return { current: 0, total };
- },
- async status() {
- console.log("[linux] status: running UNO query");
- const raw = await pyUno(`
- import sys
- try:
- import uno
- from com.sun.star.beans import PropertyValue
- localCtx = uno.getComponentContext()
- resolver = localCtx.ServiceManager.createInstanceWithContext(
- "com.sun.star.bridge.UnoUrlResolver", localCtx)
- ctx = resolver.resolve(
- "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
- smgr = ctx.ServiceManager
- desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
- comp = desktop.getCurrentComponent()
- draw = comp.DrawPages
- total = draw.Count
- current = 0
- try:
- controller = comp.getCurrentController()
- # During slideshow: PresentationController has getCurrentSlideIndex()
- if hasattr(controller, 'getCurrentSlideIndex'):
- current = controller.getCurrentSlideIndex() + 1
- print(f"[status] slideshow mode, index={current}", file=sys.stderr)
- # Editor mode: DrawController has getCurrentPage()
- elif hasattr(controller, 'getCurrentPage'):
- page = controller.getCurrentPage()
- # page.PageIndex may not exist on all objects; use DrawPages index
- for i in range(draw.Count):
- if draw.getByIndex(i) == page:
- current = i + 1
- break
- print(f"[status] editor mode, index={current}", file=sys.stderr)
- else:
- print(f"[status] unknown controller type: {type(controller)}", file=sys.stderr)
- except Exception as inner:
- print(f"[status] could not get current page: {inner}", file=sys.stderr)
- current = 0
- print(f"{current}/{total}")
- except Exception as e:
- print(f"[status] UNO error: {e}", file=sys.stderr)
- print("0/0")
- `);
- console.log(`[linux] status raw output: "${raw}"`);
- const result = parseSlide(raw);
- console.log(`[linux] status parsed: current=${result.current} total=${result.total}`);
- return result;
- },
- async slideImage(index: number) {
- console.log(`[linux] slideImage: exporting slide index=${index}`);
- const raw = await pyUno(`
- import sys, os, base64, tempfile
- try:
- import uno
- from com.sun.star.beans import PropertyValue
- localCtx = uno.getComponentContext()
- resolver = localCtx.ServiceManager.createInstanceWithContext(
- "com.sun.star.bridge.UnoUrlResolver", localCtx)
- ctx = resolver.resolve(
- "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext")
- smgr = ctx.ServiceManager
- desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
- comp = desktop.getCurrentComponent()
- draw = comp.DrawPages
- print(f"[slideImage] total slides: {draw.Count}", file=sys.stderr)
- slide = draw.getByIndex(${index - 1})
- print(f"[slideImage] got slide: {slide.Name}", file=sys.stderr)
- tmp = tempfile.mktemp(suffix='.png')
- print(f"[slideImage] exporting to: {tmp}", file=sys.stderr)
- graphicExporter = smgr.createInstanceWithContext(
- "com.sun.star.drawing.GraphicExportFilter", ctx)
- # setSourceDocument must be called with the slide (DrawPage)
- graphicExporter.setSourceDocument(slide)
- def mkprop(name, val):
- p = PropertyValue()
- p.Name = name
- p.Value = val
- return p
- # Do NOT pass Selection — setSourceDocument is sufficient
- props = (
- mkprop("URL", uno.systemPathToFileUrl(tmp)),
- mkprop("MediaType", "image/png"),
- mkprop("PixelWidth", 800),
- mkprop("PixelHeight", 600),
- )
- graphicExporter.filter(props)
- if os.path.exists(tmp):
- size = os.path.getsize(tmp)
- print(f"[slideImage] exported file size: {size} bytes", file=sys.stderr)
- with open(tmp, 'rb') as f:
- data = f.read()
- b64 = base64.b64encode(data).decode()
- print(f"[slideImage] base64 length: {len(b64)}", file=sys.stderr)
- print(b64)
- os.unlink(tmp)
- else:
- print(f"[slideImage] export file not created at {tmp}", file=sys.stderr)
- print("")
- except Exception as e:
- import traceback
- print(f"[slideImage] exception: {e}", file=sys.stderr)
- traceback.print_exc(file=sys.stderr)
- print("")
- `);
- const trimmed = raw.trim();
- console.log(`[linux] slideImage: got output length=${trimmed.length}${trimmed.length === 0 ? " (EMPTY — check stderr above)" : ""}`);
- return trimmed;
- },
- };
- // ─── Linux: LibreOffice process manager ──────────────────────────────────────
- // Serves a web file picker at http://<host>:PORT/picker.
- // When a file is selected it POSTs to /picker/open, which launches LibreOffice.
- // Re-prompts (via broadcast) when LibreOffice exits.
- const UNO_PORT = 2002;
- let libreofficeProc: ReturnType<typeof Bun.spawn> | null = null;
- // Resolves when the user picks a file via the web UI
- let pickerResolve: ((path: string) => void) | null = null;
- function waitForFilePick(): Promise<string> {
- return new Promise((resolve) => {
- pickerResolve = resolve;
- });
- }
- const PICKER_HTML = `<!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Open Presentation — PPT Remote</title>
- <style>
- * { box-sizing: border-box; margin: 0; padding: 0; }
- body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; }
- header { background: #16213e; padding: 12px 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
- header h1 { font-size: 1rem; font-weight: 600; flex: 1; }
- 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; }
- header button:hover { background: #e94560; }
- #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; }
- #breadcrumb .crumb { cursor: pointer; color: #e94560; } #breadcrumb .crumb:hover { text-decoration: underline; }
- #breadcrumb .sep { color: #444; }
- /* Drop zone */
- #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; }
- #dropzone.drag-over { border-color: #e94560; background: rgba(233,69,96,0.08); color: #e0e0e0; }
- #dropzone span { font-size: 1.6rem; display: block; margin-bottom: 6px; }
- #list { padding: 8px 16px 80px; flex: 1; overflow-y: auto; }
- .entry { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s; user-select: none; }
- .entry:hover { background: #0f3460; }
- .entry .icon { font-size: 1.2rem; width: 26px; text-align: center; flex-shrink: 0; }
- .entry .name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .entry .name.file { color: #a8d8ea; }
- #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; }
- #status.show { display: block; }
- .loading { text-align: center; padding: 40px; color: #555; }
- </style>
- </head>
- <body>
- <header>
- <span style="font-size:1.4rem">📂</span>
- <h1>Select a Presentation — PPT Remote</h1>
- <button onclick="browse(cwd)" title="Reload current folder">🔄 Reload</button>
- <button onclick="browse(HOME)" title="Go to home directory">🏠 Home</button>
- </header>
- <div id="breadcrumb"></div>
- <div id="dropzone" id="dropzone">
- <span>⬆️</span>
- Drag & drop a presentation file here to open it
- </div>
- <div id="list"><div class="loading">Loading…</div></div>
- <div id="status"></div>
- <script>
- const HOME = '__HOME__';
- let cwd = HOME;
- // ── Drag & drop ──────────────────────────────────────────────────────────
- const dz = document.getElementById('dropzone');
- document.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); });
- document.addEventListener('dragleave', e => { if (e.relatedTarget === null) dz.classList.remove('drag-over'); });
- document.addEventListener('drop', async e => {
- e.preventDefault();
- dz.classList.remove('drag-over');
- const files = e.dataTransfer.files;
- if (!files.length) return;
- // DataTransfer gives us File objects but not full paths in browsers.
- // We use the webkitRelativePath or name and resolve against cwd.
- const file = files[0];
- // Try to get the full path via the non-standard .path property (Electron/some browsers)
- const fullPath = file.path || (cwd.replace(/\\/$/, '') + '/' + file.name);
- await openFile(fullPath);
- });
- // ── Browsing ─────────────────────────────────────────────────────────────
- async function browse(path) {
- cwd = path;
- document.getElementById('list').innerHTML = '<div class="loading">Loading…</div>';
- const res = await fetch('/picker/ls?path=' + encodeURIComponent(path));
- const { entries, error } = await res.json();
- renderBreadcrumb(path);
- if (error) { document.getElementById('list').innerHTML = '<div class="loading">' + error + '</div>'; return; }
- renderList(entries);
- }
- function renderBreadcrumb(path) {
- const parts = path.split('/').filter(Boolean);
- let html = '<span class="crumb" onclick="browse(\\'/\\')">/ root</span>';
- let acc = '';
- for (const p of parts) {
- acc += '/' + p;
- const cur = acc;
- html += '<span class="sep">›</span><span class="crumb" onclick="browse(\\''+cur+'\\')">'+p+'</span>';
- }
- document.getElementById('breadcrumb').innerHTML = html;
- }
- function renderList(entries) {
- if (!entries.length) { document.getElementById('list').innerHTML = '<div class="loading">Empty folder</div>'; return; }
- const el = document.getElementById('list');
- el.innerHTML = '';
- if (cwd !== '/') {
- const up = document.createElement('div');
- up.className = 'entry';
- up.innerHTML = '<span class="icon">⬆️</span><span class="name">..</span>';
- up.onclick = () => browse(cwd.split('/').slice(0,-1).join('/') || '/');
- el.appendChild(up);
- }
- for (const e of entries) {
- const div = document.createElement('div');
- div.className = 'entry';
- div.innerHTML = \`<span class="icon">\${e.dir ? '📁' : '📄'}</span><span class="name \${e.dir ? '' : 'file'}">\${e.name}</span>\`;
- if (e.dir) div.onclick = () => browse(e.path);
- else div.onclick = () => openFile(e.path);
- el.appendChild(div);
- }
- }
- // ── Open file ─────────────────────────────────────────────────────────────
- async function openFile(path) {
- const st = document.getElementById('status');
- st.textContent = 'Opening: ' + path + ' …';
- st.className = 'show';
- const res = await fetch('/picker/open', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path })
- });
- const data = await res.json();
- if (data.ok) {
- st.textContent = '✅ Opened! You can close this tab.';
- } else {
- st.textContent = '❌ Error: ' + data.error;
- }
- }
- browse(cwd);
- </script>
- </body>
- </html>`;
- async function servePickerRequest(req: Request): Promise<Response | null> {
- const url = new URL(req.url);
- if (url.pathname === "/picker") {
- const home = process.env.HOME || "/root";
- const html = PICKER_HTML.replace("'__HOME__'", JSON.stringify(home));
- return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
- }
- if (url.pathname === "/picker/ls") {
- const home = process.env.HOME || "/root";
- const dirPath = url.searchParams.get("path") || home;
- try {
- const entries: { name: string; path: string; dir: boolean }[] = [];
- const dir = await import("node:fs/promises");
- const items = await dir.readdir(dirPath, { withFileTypes: true });
- for (const entry of items) {
- if (entry.name.startsWith(".")) continue;
- entries.push({
- name: entry.name,
- path: `${dirPath.replace(/\/$/, "")}/${entry.name}`,
- dir: entry.isDirectory(),
- });
- }
- // dirs first, then files, both alphabetical
- entries.sort((a, b) => {
- if (a.dir !== b.dir) return a.dir ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
- return Response.json({ entries });
- } catch (e) {
- return Response.json({ entries: [], error: String(e) });
- }
- }
- if (url.pathname === "/picker/open" && req.method === "POST") {
- const { path } = await req.json() as { path: string };
- if (!path) return Response.json({ ok: false, error: "No path provided" });
- if (pickerResolve) {
- pickerResolve(path);
- pickerResolve = null;
- return Response.json({ ok: true });
- }
- // No active picker waiting — launch directly (e.g. re-open after close)
- launchLibreOffice(path).catch(console.error);
- return Response.json({ ok: true });
- }
- return null; // not a picker route
- }
- async function launchLibreOffice(filePath: string): Promise<void> {
- console.log(`Opening: ${filePath}`);
- libreofficeProc = Bun.spawn([
- "libreoffice",
- `--accept=socket,host=localhost,port=${UNO_PORT};urp;StarOffice.ServiceManager`,
- "--impress",
- filePath,
- ], { stdout: "inherit", stderr: "inherit" });
- // Wait for UNO socket to become available (up to 10s)
- for (let i = 0; i < 20; i++) {
- await Bun.sleep(500);
- try {
- const status = await LinuxDriver.status();
- if (status.total > 0) break;
- } catch { /* not ready yet */ }
- }
- broadcast({ event: "presentation", status: "opened", file: filePath.split("/").pop() });
- // Watch for process exit, then re-prompt
- libreofficeProc.exited.then(async () => {
- console.log("LibreOffice closed.");
- broadcast({ event: "presentation", status: "closed" });
- libreofficeProc = null;
- await promptAndLaunch();
- });
- }
- async function promptAndLaunch(): Promise<void> {
- console.log(`Open a presentation at http://localhost:${PORT}/picker`);
- broadcast({ event: "presentation", status: "none", pickerUrl: `/picker` });
- const file = await waitForFilePick();
- await launchLibreOffice(file);
- }
- // ─── Select driver based on platform ─────────────────────────────────────────
- const driver: Driver = IS_LINUX ? LinuxDriver : WindowsDriver;
- console.log(`Platform: ${process.platform} — using ${IS_LINUX ? "LibreOffice/Linux" : "PowerPoint/Windows"} driver`);
- // ─── WebSocket server ─────────────────────────────────────────────────────────
- const clients = new Set<import("bun").ServerWebSocket<unknown>>();
- function broadcast(payload: object) {
- const msg = JSON.stringify(payload);
- for (const ws of clients) ws.send(msg);
- }
- async function handleCmd(cmd: string): Promise<void> {
- const navCmds = ["next", "prev", "start", "end", "status"] as const;
- type NavCmd = typeof navCmds[number];
- if (!(navCmds as readonly string[]).includes(cmd)) {
- broadcast({ event: "error", message: `Unknown command: ${cmd}` });
- return;
- }
- console.log(`[cmd] executing: ${cmd}`);
- const { current, total } = await driver[cmd as NavCmd]();
- console.log(`[cmd] result: current=${current} total=${total}`);
- broadcast({ event: "slide", current, total });
- // Request image: use current if known, fall back to 1 for navigation cmds
- // where slideshow may be active but status returned 0
- const imageIndex = current > 0 ? current : (cmd !== "end" && total > 0 ? 1 : 0);
- if (imageIndex > 0) {
- console.log(`[cmd] requesting slide image for index=${imageIndex}`);
- const b64 = await driver.slideImage(imageIndex);
- if (b64) {
- console.log(`[cmd] broadcasting image, base64 length=${b64.length}`);
- broadcast({ event: "image", data: b64 });
- } else {
- console.warn(`[cmd] slideImage returned empty for index=${imageIndex}`);
- }
- }
- }
- const server = Bun.serve({
- port: PORT,
- fetch(req, server) {
- // Linux file picker routes
- if (IS_LINUX && req.url.includes("/picker")) {
- return servePickerRequest(req).then(r => r ?? new Response("Not found", { status: 404 }));
- }
- if (!req.headers.get("upgrade")) {
- const pickerNote = IS_LINUX ? `\nFile picker: http://<host>:${PORT}/picker` : "";
- return new Response(
- `PPT Remote Server — ${IS_LINUX ? "LibreOffice" : "PowerPoint"} mode\n` +
- `WebSocket: ws://<host>:${PORT}` + pickerNote,
- { headers: { "Content-Type": "text/plain" } }
- );
- }
- const ok = server.upgrade(req);
- return ok ? undefined : new Response("WebSocket upgrade failed", { status: 500 });
- },
- websocket: {
- open(ws) {
- clients.add(ws);
- console.log(`Client connected (${clients.size} total)`);
- driver.status().then(async ({ current, total }) => {
- ws.send(JSON.stringify({ event: "slide", current, total }));
- if (current > 0) {
- const b64 = await driver.slideImage(current);
- if (b64) ws.send(JSON.stringify({ event: "image", data: b64 }));
- }
- });
- },
- async message(ws, message) {
- let cmd: string;
- try {
- ({ cmd } = JSON.parse(message as string));
- } catch {
- ws.send(JSON.stringify({ event: "error", message: "Invalid JSON" }));
- return;
- }
- try {
- await handleCmd(cmd);
- } catch (err) {
- console.error("[cmd error]", err);
- ws.send(JSON.stringify({ event: "error", message: String(err) }));
- }
- },
- close(ws) {
- clients.delete(ws);
- console.log(`Client disconnected (${clients.size} total)`);
- },
- },
- });
- console.log(`PPT Remote Server listening on ws://0.0.0.0:${PORT}`);
- // On Linux, prompt to open a presentation immediately
- if (IS_LINUX) promptAndLaunch();
- else console.log("Make sure PowerPoint is open with a presentation before connecting.");
|