From 1c9422cc8b68eba269fdce69eda8f06ced4db348 Mon Sep 17 00:00:00 2001 From: tgt Date: Sun, 19 Jan 2025 16:54:36 -0500 Subject: [PATCH] feat: audio impl in wrkspce+client, improve vnc viewer, fix file listing and screenshot (kinda), fix config file resolving --- .gitignore | 1 + apps/daemon/src/index.ts | 3 +- apps/daemon/src/server.ts | 16 +- apps/daemon/src/session/file.ts | 25 +-- apps/daemon/src/session/index.ts | 21 +-- apps/daemon/src/session/screenshot.ts | 10 +- apps/daemon/src/stardustd.plist | 21 +-- apps/daemon/src/stardustd.service | 2 +- apps/web/package.json | 4 +- apps/web/server.ts | 57 +------ .../src/app/api/session/[slug]/files/route.ts | 6 +- .../app/api/session/[slug]/preview/route.ts | 13 +- apps/web/src/app/api/session/[slug]/route.ts | 1 - apps/web/src/app/view/[slug]/layout.tsx | 14 ++ apps/web/src/app/view/[slug]/page.tsx | 73 +++++---- apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/components/ui/textarea.tsx | 2 +- apps/web/src/components/vnc-screen.tsx | 2 +- packages/common/package.json | 3 +- packages/common/session/client/audio.ts | 145 ++++++++++++++++++ packages/common/session/ws.ts | 57 +++++++ packages/config/index.ts | 3 +- packages/config/load-config.ts | 4 +- packages/db/package.json | 4 +- packages/db/seed.ts | 1 + pnpm-lock.yaml | 18 ++- turbo.json | 6 +- workspaces/chromium/Dockerfile | 2 +- workspaces/chromium/xstartup | 7 +- 29 files changed, 350 insertions(+), 173 deletions(-) create mode 100644 apps/web/src/app/view/[slug]/layout.tsx create mode 100644 packages/common/session/client/audio.ts create mode 100644 packages/common/session/ws.ts diff --git a/.gitignore b/.gitignore index 0461c28..941acdb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ tsconfig.tsbuildinfo npm-debug.log* yarn-debug.log* yarn-error.log* +std*.log # Misc .DS_Store diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 4f8e0e3..8afbe3a 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -14,7 +14,8 @@ export const app = new Elysia() }; }) .onBeforeHandle(authCheck) - .onError(({ error }) => { + .onError(({ error, path }) => { + console.error(`✨ Stardust: [${path}] ${error}`); return { success: false, error: error.toString(), diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 1c059bf..061b98a 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -16,20 +16,24 @@ const config = getConfig(); if (typeof config.service !== "boolean" || config.service === true) { checkSystemService(); } -const srv = Bun.serve<{ socket: Socket }>({ +const srv = Bun.serve<{ socket: Socket; path: string }>({ async fetch(req, server) { const path = new URL(req.url).pathname; - if (path.startsWith("/sessions") && path.split("/")[3] === "vnc") { + const portMap: Record = { + vnc: 5901, + audio: 4713, + }; + if (path.startsWith("/sessions") && ["vnc", "audio"].includes(path.split("/")[3])) { const containerInfo = await docker.getContainer(path.split("/")[2]).inspect(); const socket = connect( - 5901, + portMap[path.split("/")[3]], containerInfo.NetworkSettings.Networks[config.docker.network || "stardust"].IPAddress, ); const authToken = await generateToken(); if (req.headers.get("Authorization") === authToken) { if ( server.upgrade(req, { - data: { socket }, + data: { socket, path }, }) ) { return; @@ -45,7 +49,7 @@ const srv = Bun.serve<{ socket: Socket }>({ }); ws.data.socket.on("error", (err) => { - console.warn(`✨ Stardust: ${err.message}`); + console.error(`✨ Stardust: [${ws.data.path}] ${err.message}`); ws.close(); }); @@ -58,7 +62,7 @@ const srv = Bun.serve<{ socket: Socket }>({ }, close(ws, code, reason) { console.info( - `✨ Stardust: Connection closed with code ${code} and ${ + `✨ Stardust: [${ws.data.path}] Connection closed with code ${code} and ${ reason.toString() ? `reason ${reason.toString()}` : "no reason" }`, ); diff --git a/apps/daemon/src/session/file.ts b/apps/daemon/src/session/file.ts index f5590d6..3ed8abb 100644 --- a/apps/daemon/src/session/file.ts +++ b/apps/daemon/src/session/file.ts @@ -1,4 +1,5 @@ import { docker } from "~/lib/docker"; +// broken export async function sendFile(id: string, name: string, file: Uint8Array) { const container = docker.getContainer(id); const payload = Buffer.from(Bun.gzipSync(file)); @@ -11,9 +12,10 @@ export async function sendFile(id: string, name: string, file: Uint8Array) { }); return true; } - +// broken export async function getFile(id: string, name: string) { const file = await docker.getContainer(id).getArchive({ path: `/home/stardust/Downloads/${name}` }); + console.log(file.read()); if (!file) throw new Error("no file for some reason"); const unzipped = Bun.gunzipSync(file.read()); return unzipped; @@ -21,17 +23,22 @@ export async function getFile(id: string, name: string) { export async function listFiles(id: string) { const exec = await docker.getContainer(id).exec({ - Cmd: ["sh", "-c", "ls /home/stardust/Downloads"], + Cmd: ["sh", "-c", "mkdir -p /home/stardust/Downloads;ls /home/stardust/Downloads"], AttachStdout: true, AttachStderr: true, }); - const stream = await exec.start({ hijack: true, stdin: true }); - const data = await new Promise((res, err) => { - const out: string[] = []; - stream.on("error", err); - stream.on("data", (chunk) => out.push(chunk.toString())); - stream.on("end", () => res(out.join(""))); - }); + const stream = await exec.start({}); + const data = ( + await new Promise((res, err) => { + const out: string[] = []; + stream.on("error", err); + stream.on("data", (chunk) => out.push(chunk.toString())); + stream.on("end", () => res(out.join(""))); + }) + ) + .split("\n") + .filter(Boolean) + .map((s) => s.replace("\x01\x00\x00\x00\x00\x00\x00\x1C", "")); return data; } diff --git a/apps/daemon/src/session/index.ts b/apps/daemon/src/session/index.ts index 1574eee..d6cbe26 100644 --- a/apps/daemon/src/session/index.ts +++ b/apps/daemon/src/session/index.ts @@ -1,6 +1,4 @@ -import { connect } from "node:net"; import { Elysia, t } from "elysia"; -import generateToken from "~/lib/auth-token"; import { getConfig } from "~/lib/config"; import { docker } from "~/lib/docker"; import createSession from "./create"; @@ -71,18 +69,9 @@ export default new Elysia({ prefix: "/sessions" }) await deleteSession(id); return { success: true }; }) - .get("/:id/screenshot", async ({ params: { id }, set }) => { - try { - const res = await screenshot(id); - set.headers["Content-Type"] = "image/png"; - return res; - } catch (e) { - set.status = 500; - return { - success: false, - error: e, - }; - } + .get("/:id/screenshot", async ({ params: { id } }) => { + const res = await screenshot(id); + return { success: true, encoded: res }; }) .group("/:id/files", (app) => app @@ -90,19 +79,21 @@ export default new Elysia({ prefix: "/sessions" }) const data = await listFiles(id); return { success: true, - list: data.split("\n").filter(Boolean), + list: data, }; }) .get("/download/:name", async ({ params: { id, name } }) => getFile(id, name)) .put( "/upload/:name", async ({ params: { id, name }, body }) => { + console.log(body); const res = await sendFile(id, name, body); return { success: res, }; }, { + parse: "none", body: t.Uint8Array(), }, ), diff --git a/apps/daemon/src/session/screenshot.ts b/apps/daemon/src/session/screenshot.ts index 26ec8a0..01359d2 100644 --- a/apps/daemon/src/session/screenshot.ts +++ b/apps/daemon/src/session/screenshot.ts @@ -1,19 +1,17 @@ import { docker } from "~/lib/docker"; export default async function screenshot(id: string) { - const container = docker.getContainer(id); - const exec = await container.exec({ - Cmd: ["sh", "-c", "xwd -root | convert xwd:- png:- | base64"], + const exec = await docker.getContainer(id).exec({ + Cmd: ["sh", "-c", "xwd -root -display :1 | convert xwd:- png:- | base64"], AttachStdout: true, AttachStderr: true, }); - const stream = await exec.start({ hijack: true, stdin: true }); + const stream = await exec.start({}); const encoded = await new Promise((res, err) => { const out: string[] = []; stream.on("error", err); stream.on("data", (chunk) => out.push(chunk.toString())); stream.on("end", () => res(out.join(""))); }); - const file = Buffer.from(encoded, "base64"); - return file; + return encoded.replaceAll(/[^A-Za-z0-9+/=]/g, ""); } diff --git a/apps/daemon/src/stardustd.plist b/apps/daemon/src/stardustd.plist index b3af12f..037084b 100644 --- a/apps/daemon/src/stardustd.plist +++ b/apps/daemon/src/stardustd.plist @@ -2,41 +2,26 @@ - Label org.spaceness.stardustd.plist - RunAtLoad - KeepAlive - UserName root - StandardErrorPath - /usr/local/opt/stardust/stderr.log - + /opt/stardust/stderr.log StandardOutPath - /usr/local/opt/stardust/stdout.log - + /opt/stardust/stdout.log EnvironmentVariables PATH - - WorkingDirectory - /usr/local/opt/stardust/apps/daemon - ProgramArguments - - - /usr/local/bin/pnpm - start + /opt/stardust/apps/daemon/stardustd - diff --git a/apps/daemon/src/stardustd.service b/apps/daemon/src/stardustd.service index 8b93660..021557c 100644 --- a/apps/daemon/src/stardustd.service +++ b/apps/daemon/src/stardustd.service @@ -4,7 +4,7 @@ After=docker.service [Service] WorkingDirectory=/opt/stardust/apps/daemon/ -ExecStart=/usr/bin/bun start +ExecStart=stardustd Restart=on-failure RestartSec=5 diff --git a/apps/web/package.json b/apps/web/package.json index 6821fb1..aae5f98 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,7 +35,6 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "geist": "^1.3.1", - "http-proxy-middleware": "^3.0.3", "lucide-react": "^0.460.0", "next": "15.1.5", "next-themes": "^0.4.4", @@ -46,7 +45,8 @@ "swr": "^2.3.0", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.15", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "use-local-storage": "^3.0.0" }, "devDependencies": { "@types/node": "^20", diff --git a/apps/web/server.ts b/apps/web/server.ts index 01e7adf..d143d41 100644 --- a/apps/web/server.ts +++ b/apps/web/server.ts @@ -1,15 +1,10 @@ import "@stardust/config/load-config"; import { createServer } from "node:http"; -import type { Socket } from "node:net"; -import type { SessionSchema } from "@stardust/common/auth"; +import { shouldRoute, stardustdUpgrade } from "@stardust/common/session/ws"; import { getConfig } from "@stardust/config"; -import db, { session } from "@stardust/db"; -import { eq } from "@stardust/db/utils"; -import { createProxyMiddleware } from "http-proxy-middleware"; import next from "next"; const dev = process.env.NODE_ENV !== "production"; -const config = getConfig(); -const port = config.port || 3000; +const port = getConfig().port || 3000; console.log( `✨ Stardust: Starting ${dev ? "development" : "production"} server ${process.argv.includes("--turbo") ? "with turbopack" : ""}...`, ); @@ -27,51 +22,9 @@ const nextRequest = app.getRequestHandler(); const nextUpgrade = app.getUpgradeHandler(); httpServer .on("request", nextRequest) - .on("upgrade", async (req, socket, head) => { - if (req.url?.startsWith("/vnc") && req.url?.split("/")[2]) { - const proto = req.headers["x-forwarded-proto"] || "http"; - const host = req.headers["x-forwarded-host"] || req.headers.host; - const res = await fetch(`${proto}://${host}/api/auth/get-session`, { - headers: { - cookie: req.headers.cookie || "", - }, - }); - const userSession: SessionSchema = await res.json(); - const dbSession = await db.query.session.findFirst({ - where: (session, { and, eq }) => - and(eq(session.id, req.url?.split("/")[2] as string), eq(session.userId, userSession?.user.id || "")), - }); - if (!dbSession || dbSession?.userId !== userSession?.user.id) return socket.end(); - const nodeConfig = config.nodes.find(({ id }) => id === dbSession.node); - const intervalId = setInterval(async () => { - try { - console.log(`✨ Stardust: Updating keepalive for session ${dbSession?.id}`); - const expiresAt = new Date(); - expiresAt.setMinutes(expiresAt.getMinutes() + (config.session?.keepaliveDuration || 1440)); - await db - .update(session) - .set({ expiresAt }) - .where(eq(session.id, dbSession?.id || "")); - } catch (e) { - console.log(`✨ Stardust: Error updating keepalive for session ${dbSession?.id} - ${e}`); - } - }, 60000); - - socket.on("close", () => { - console.log(`✨ Stardust: Client disconnected from ${dbSession?.id}`); - clearInterval(intervalId); - }); - const middleware = createProxyMiddleware({ - target: `ws://${nodeConfig?.hostname || "0.0.0.0"}:${nodeConfig?.port || 4000}/sessions/${dbSession.id}/vnc`, - ignorePath: true, - headers: { - Authorization: nodeConfig?.token as string, - }, - }); - return middleware.upgrade(req, socket as Socket, head); - } - nextUpgrade(req, socket, head); - }) + .on("upgrade", (req, socket, head) => + shouldRoute(req) ? stardustdUpgrade(req, socket, head) : nextUpgrade(req, socket, head), + ) .listen(port, () => { console.log(`✨ Stardust: Server listening on ${port}`); }); diff --git a/apps/web/src/app/api/session/[slug]/files/route.ts b/apps/web/src/app/api/session/[slug]/files/route.ts index 119aba8..74c854d 100644 --- a/apps/web/src/app/api/session/[slug]/files/route.ts +++ b/apps/web/src/app/api/session/[slug]/files/route.ts @@ -9,7 +9,7 @@ export async function GET(req: NextRequest, props: { params: Promise<{ slug: str const nodeSession = getNode(session).sessions({ id: session.id }); if (name) { const { data, error } = await nodeSession.files.download({ name }).get(); - if (error) return Response.json({ error }, { status: 500 }); + if (error) return Response.json(error, { status: 500 }); return new Response(new Blob([data]), { headers: { "Content-Disposition": `attachment; filename=${name}`, @@ -18,7 +18,7 @@ export async function GET(req: NextRequest, props: { params: Promise<{ slug: str }); } const { data, error } = await nodeSession.files.list.get(); - if (error) return Response.json({ error }, { status: 500 }); + if (error) return Response.json(error, { status: 500 }); return Response.json(data.list); } export async function PUT(req: NextRequest, props: { params: Promise<{ slug: string }> }) { @@ -28,6 +28,6 @@ export async function PUT(req: NextRequest, props: { params: Promise<{ slug: str const session = await getSession(params.slug); const nodeSession = getNode(session).sessions({ id: session.id }); const { data, error } = await nodeSession.files.upload({ name }).put(new Uint8Array(await req.arrayBuffer())); - if (error) return Response.json({ error }, { status: 500 }); + if (error) return Response.json(error, { status: 500 }); return Response.json(data); } diff --git a/apps/web/src/app/api/session/[slug]/preview/route.ts b/apps/web/src/app/api/session/[slug]/preview/route.ts index 0577851..b9d5e86 100644 --- a/apps/web/src/app/api/session/[slug]/preview/route.ts +++ b/apps/web/src/app/api/session/[slug]/preview/route.ts @@ -7,12 +7,9 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ slug: st const nodeSession = getNode(session).sessions({ id: session.id }); const { data, error } = await nodeSession.screenshot.get(); if (error) return Response.json({ error }, { status: 500 }); - if (data instanceof Buffer) { - return new Response(data, { - headers: { - "Content-Type": "image/png", - }, - }); - } - return Response.json(data, { status: 500 }); + return new Response(Buffer.from(data.encoded, "base64"), { + headers: { + "Content-Type": "image/png", + }, + }); } diff --git a/apps/web/src/app/api/session/[slug]/route.ts b/apps/web/src/app/api/session/[slug]/route.ts index 36c1108..3a52e78 100644 --- a/apps/web/src/app/api/session/[slug]/route.ts +++ b/apps/web/src/app/api/session/[slug]/route.ts @@ -27,7 +27,6 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ slug: st return Response.json({ exists: true, password: data.password, - url: `/vnc/${session.id}`, }); } export const dynamic = "force-dynamic"; diff --git a/apps/web/src/app/view/[slug]/layout.tsx b/apps/web/src/app/view/[slug]/layout.tsx new file mode 100644 index 0000000..01bbc63 --- /dev/null +++ b/apps/web/src/app/view/[slug]/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +export async function generateMetadata(props: { params: Promise<{ slug: string }> }): Promise { + const params = await props.params; + return { + title: `Session ${params.slug.slice(0, 6)}`, + }; +} +export default function ViewLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/apps/web/src/app/view/[slug]/page.tsx b/apps/web/src/app/view/[slug]/page.tsx index b73206f..d675ac5 100644 --- a/apps/web/src/app/view/[slug]/page.tsx +++ b/apps/web/src/app/view/[slug]/page.tsx @@ -50,14 +50,20 @@ import { use, useEffect, useRef, useState } from "react"; import { deleteSession, manageSession } from "@/lib/session/manage"; import { fetcher } from "@/lib/utils"; +import VncAudio from "@stardust/common/session/client/audio"; import { toast } from "sonner"; import useSWR from "swr"; +import useLocalStorage from "use-local-storage"; import type { VncViewerHandle } from "@/components/vnc-screen"; type ScalingValues = "remote" | "local" | "none"; import { Loader2 } from "lucide-react"; +const VncScreen = dynamic(() => import("@/components/vnc-screen"), { + loading: () => , +}); + function Loading({ text }: { text: string }) { return (
@@ -75,33 +81,30 @@ function ConnectionAlert({ text, error }: { text: string; error?: boolean }) {
); } -const VncScreen = dynamic(() => import("@/components/vnc-screen"), { - loading: () => , -}); + export default function View(props: { params: Promise<{ slug: string }> }) { const params = use(props.params); const vncRef = useRef(null); + const audioRef = useRef(null); const [connected, setConnected] = useState(false); const [fullScreen, setFullScreen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [workingClipboard, setWorkingClipboard] = useState(true); // noVNC options start const [clipboard, setClipboard] = useState(""); - const [viewOnly, setViewOnly] = useState(false); - const [qualityLevel, setQualityLevel] = useState(6); - const [compressionLevel, setCompressionLevel] = useState(2); - const [clipViewport, setClipViewport] = useState(false); - const [scaling, setScaling] = useState("remote"); + const [viewOnly, setViewOnly] = useLocalStorage("stardust_viewonly", false); + const [qualityLevel, setQualityLevel] = useLocalStorage("stardust_qualitylevel", 6); + const [compressionLevel, setCompressionLevel] = useLocalStorage("stardust_compressionlevel", 2); + const [clipViewport, setClipViewport] = useLocalStorage("stardust_clipviewport", false); + const [scaling, setScaling] = useLocalStorage("stardust_scaling", "remote"); // noVNC options end const router = useRouter(); const { data: session, error: sessionError, isLoading: sessionLoading, - mutate: sessionMutate, } = useSWR<{ exists: boolean; - url?: string; error?: string; password?: string; } | null>(`/api/session/${params.slug}`, fetcher, { @@ -118,20 +121,6 @@ export default function View(props: { params: Promise<{ slug: string }> }) { } = useSWR(`/api/session/${params.slug}/files`, fetcher, { refreshInterval: 10000, }); - useEffect(() => { - if (connected && vncRef.current?.rfb) { - vncRef.current.rfb.viewOnly = viewOnly; - vncRef.current.rfb.qualityLevel = qualityLevel; - vncRef.current.rfb.compressionLevel = compressionLevel; - vncRef.current.rfb.clipViewport = clipViewport; - vncRef.current.rfb.resizeSession = scaling === "remote"; - vncRef.current.rfb.scaleViewport = scaling === "local"; - } - }, [connected, viewOnly, qualityLevel, compressionLevel, scaling, clipViewport]); - // why did i even use swr for this raaaaaaah - useEffect(() => { - if (!session) sessionMutate(); - }, [session, sessionMutate]); useEffect(() => { const requestClipboardPermissions = async () => { try { @@ -147,6 +136,28 @@ export default function View(props: { params: Promise<{ slug: string }> }) { }; requestClipboardPermissions(); }, []); + useEffect(() => { + if (session) + audioRef.current = new VncAudio( + `${window.location.protocol.replace("http", "ws")}//${window.location.host}/audio/${params.slug}`, + ); + return () => { + audioRef.current = null; + }; + }, [session, params.slug]); + useEffect(() => { + if (connected && document.hasFocus()) audioRef.current?.start(); + }, [connected]); + useEffect(() => { + if (connected && vncRef.current?.rfb) { + vncRef.current.rfb.viewOnly = viewOnly; + vncRef.current.rfb.qualityLevel = qualityLevel; + vncRef.current.rfb.compressionLevel = compressionLevel; + vncRef.current.rfb.clipViewport = clipViewport; + vncRef.current.rfb.resizeSession = scaling === "remote"; + vncRef.current.rfb.scaleViewport = scaling === "local"; + } + }, [connected, viewOnly, qualityLevel, compressionLevel, scaling, clipViewport]); useEffect(() => { const interval = setInterval(() => { if (workingClipboard && document.hasFocus()) { @@ -202,7 +213,7 @@ export default function View(props: { params: Promise<{ slug: string }> }) { filesMutate(); }} > - + Control Panel @@ -239,7 +250,7 @@ export default function View(props: { params: Promise<{ slug: string }> }) { - Confirm session deletion @@ -325,7 +335,7 @@ export default function View(props: { params: Promise<{ slug: string }> }) {