|
|
@@ -250,28 +250,166 @@ except Exception as e:
|
|
|
};
|
|
|
|
|
|
// ─── Linux: LibreOffice process manager ──────────────────────────────────────
|
|
|
-// Prompts the user to pick a presentation file via zenity (GTK file dialog),
|
|
|
-// launches LibreOffice with the UNO socket listener, and re-prompts when
|
|
|
-// the process exits.
|
|
|
+// 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;
|
|
|
|
|
|
-async function pickFile(): Promise<string | null> {
|
|
|
- try {
|
|
|
- const result = await run("zenity", [
|
|
|
- "--file-selection",
|
|
|
- "--title=Open Presentation",
|
|
|
- ]);
|
|
|
- return result || null;
|
|
|
- } catch {
|
|
|
- // zenity not available — fall back to terminal prompt
|
|
|
- process.stdout.write("Enter path to presentation file: ");
|
|
|
- for await (const line of console) {
|
|
|
- return line.trim() || 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; }
|
|
|
+ header { background: #16213e; padding: 16px 24px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #0f3460; }
|
|
|
+ header h1 { font-size: 1.1rem; font-weight: 600; }
|
|
|
+ #breadcrumb { padding: 10px 24px; background: #16213e; font-size: 0.85rem; color: #888; border-bottom: 1px solid #0f3460; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
|
+ #breadcrumb span { cursor: pointer; color: #e94560; } #breadcrumb span:hover { text-decoration: underline; }
|
|
|
+ #breadcrumb .sep { color: #555; }
|
|
|
+ #list { padding: 12px 16px; }
|
|
|
+ .entry { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s; user-select: none; }
|
|
|
+ .entry:hover { background: #0f3460; }
|
|
|
+ .entry .icon { font-size: 1.3rem; width: 28px; text-align: center; flex-shrink: 0; }
|
|
|
+ .entry .name { flex: 1; font-size: 0.95rem; 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 24px; font-size: 0.9rem; display: none; }
|
|
|
+ #status.show { display: block; }
|
|
|
+ .loading { text-align: center; padding: 40px; color: #555; }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<header>
|
|
|
+ <span style="font-size:1.5rem">📂</span>
|
|
|
+ <h1>Select a Presentation — PPT Remote</h1>
|
|
|
+</header>
|
|
|
+<div id="breadcrumb"></div>
|
|
|
+<div id="list"><div class="loading">Loading…</div></div>
|
|
|
+<div id="status"></div>
|
|
|
+<script>
|
|
|
+ let cwd = '/';
|
|
|
+
|
|
|
+ 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 onclick="browse(\\'/\\')">/ root</span>';
|
|
|
+ let acc = '';
|
|
|
+ for (const p of parts) {
|
|
|
+ acc += '/' + p;
|
|
|
+ const cur = acc;
|
|
|
+ html += '<span class="sep">›</span><span 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 = '';
|
|
|
+ // parent dir
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 { ok, error } = await res.json();
|
|
|
+ if (ok) {
|
|
|
+ st.textContent = '✅ Opened! You can close this tab.';
|
|
|
+ } else {
|
|
|
+ st.textContent = '❌ Error: ' + error;
|
|
|
}
|
|
|
- return null;
|
|
|
}
|
|
|
+
|
|
|
+ browse(cwd);
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>`;
|
|
|
+
|
|
|
+async function servePickerRequest(req: Request): Promise<Response | null> {
|
|
|
+ const url = new URL(req.url);
|
|
|
+
|
|
|
+ if (url.pathname === "/picker") {
|
|
|
+ return new Response(PICKER_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (url.pathname === "/picker/ls") {
|
|
|
+ const dirPath = url.searchParams.get("path") || "/";
|
|
|
+ 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> {
|
|
|
@@ -304,12 +442,9 @@ async function launchLibreOffice(filePath: string): Promise<void> {
|
|
|
}
|
|
|
|
|
|
async function promptAndLaunch(): Promise<void> {
|
|
|
- const file = await pickFile();
|
|
|
- if (!file) {
|
|
|
- console.log("No file selected. Waiting... (clients will be notified when a file is opened)");
|
|
|
- broadcast({ event: "presentation", status: "none" });
|
|
|
- return;
|
|
|
- }
|
|
|
+ console.log(`Open a presentation at http://localhost:${PORT}/picker`);
|
|
|
+ broadcast({ event: "presentation", status: "none", pickerUrl: `/picker` });
|
|
|
+ const file = await waitForFilePick();
|
|
|
await launchLibreOffice(file);
|
|
|
}
|
|
|
|
|
|
@@ -349,10 +484,16 @@ 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}`,
|
|
|
+ `WebSocket: ws://<host>:${PORT}` + pickerNote,
|
|
|
{ headers: { "Content-Type": "text/plain" } }
|
|
|
);
|
|
|
}
|