diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..4f54539
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+
+export const LoginPage: React.FC = () => {
+ const navigate = useNavigate();
+ const [username, setUsername] = React.useState('');
+ const [password, setPassword] = React.useState('');
+ const [error, setError] = React.useState('');
+ const [loading, setLoading] = React.useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ try {
+ const res = await fetch('/api/v1/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (res.status === 401) {
+ setError('Invalid username or password');
+ return;
+ }
+ if (!res.ok) {
+ setError('Login failed, please try again');
+ return;
+ }
+ navigate('/ui/', { replace: true });
+ } catch {
+ setError('Network error, please try again');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/src/pages/VNCView.test.tsx b/src/pages/VNCView.test.tsx
new file mode 100644
index 0000000..93df5c4
--- /dev/null
+++ b/src/pages/VNCView.test.tsx
@@ -0,0 +1,540 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, screen, waitFor, act, cleanup, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Routes, Route } from "react-router-dom";
+import { VNCView } from "./VNCView";
+import { NAME_PREFIX, VERSION_PREFIX } from "../lib/vncStore";
+
+const navigateMock = vi.fn();
+
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual
("react-router-dom");
+ return { ...actual, useNavigate: () => navigateMock };
+});
+
+const rfb = vi.hoisted(() => {
+ const instances: any[] = [];
+ class FakeRFB {
+ url: string;
+ options: any;
+ listeners: Record void>> = {};
+ sendCredentials = vi.fn();
+ disconnect = vi.fn();
+ clipboardPasteFrom = vi.fn();
+ sendKey = vi.fn();
+ requestDesktopSize = vi.fn();
+ scaleViewport = false;
+ viewOnly = false;
+ localCursor = false;
+ clipViewport = false;
+ viewportDrag = false;
+ constructor(_target: any, url: string, options: any) {
+ this.url = url;
+ this.options = options;
+ instances.push(this);
+ }
+ addEventListener(type: string, cb: (e: any) => void) {
+ (this.listeners[type] ||= []).push(cb);
+ }
+ emit(type: string, detail?: any) {
+ (this.listeners[type] || []).forEach((cb) => cb({ detail }));
+ }
+ }
+ return { instances, FakeRFB };
+});
+
+vi.mock("@novnc/novnc/lib/rfb", () => ({ default: rfb.FakeRFB }));
+vi.mock("../assets/icons/clipboard-paste.svg", () => ({ default: "paste.svg" }));
+vi.mock("../assets/icons/clipboard-copy.svg", () => ({ default: "copy.svg" }));
+vi.mock("../assets/icons/send.svg", () => ({ default: "send.svg" }));
+vi.mock("../assets/icons/circle-x.svg", () => ({ default: "x.svg" }));
+
+function mockFetch(opts: { browserName?: string | undefined; browserVersion?: string; startTime?: string; password?: string | null; sessionOk?: boolean }) {
+ if (opts.password && opts.browserName) {
+ localStorage.setItem(NAME_PREFIX + opts.browserName, opts.password);
+ }
+ global.fetch = vi.fn(async () => {
+ return {
+ ok: opts.sessionOk !== false,
+ json: async () => ({ browserId: "id-1", browserName: opts.browserName, browserVersion: opts.browserVersion ?? "123", startTime: opts.startTime ?? "2020-01-01T00:00:00Z" }),
+ } as any;
+ }) as any;
+}
+
+function renderView(id = "id-1") {
+ return render(
+
+
+ } />
+
+ ,
+ );
+}
+
+const latest = () => rfb.instances[rfb.instances.length - 1];
+const emit = (type: string, detail?: any) => act(() => latest().emit(type, detail));
+
+beforeEach(() => {
+ rfb.instances.length = 0;
+ navigateMock.mockReset();
+ localStorage.clear();
+ sessionStorage.clear();
+ vi.restoreAllMocks();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("auto-connect with a saved password", () => {
+ it("sends the saved-by-name password and reports connected", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ expect(latest().url).toContain("/api/v1/browsers/id-1/vnc");
+
+ await emit("credentialsrequired");
+ expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "secret" });
+
+ await emit("connect");
+ expect(screen.getByText("Connected")).toBeInTheDocument();
+ });
+
+ it("uses _rfb_credentials when sendCredentials is unavailable", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+
+ (latest() as any).sendCredentials = undefined;
+ await emit("credentialsrequired");
+ expect((latest() as any)._rfb_credentials).toEqual({ password: "secret" });
+ });
+});
+
+describe("saved-password retry ladder", () => {
+ it("tries the version-saved password first, then falls through to the name-saved one on securityfailure", async () => {
+ localStorage.setItem(VERSION_PREFIX + "chrome@123", "byversion");
+ localStorage.setItem(NAME_PREFIX + "chrome", "byname");
+ mockFetch({ browserName: "chrome", browserVersion: "123" });
+ renderView();
+
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("credentialsrequired");
+ expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "byversion" });
+
+ await emit("securityfailure", { reason: "bad" });
+ await waitFor(() => expect(rfb.instances.length).toBe(2));
+
+ await emit("credentialsrequired");
+ expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "byname" });
+ });
+});
+
+describe("fetch failures", () => {
+ it("prompts for manual entry when the session lookup fails", async () => {
+ mockFetch({ browserName: "chrome", sessionOk: false, startTime: "" });
+ renderView();
+
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+ expect(rfb.instances.length).toBe(0);
+ });
+});
+
+describe("manual entry", () => {
+ it("prompts when no candidate exists, connects, and saves for all by name", async () => {
+ mockFetch({ browserName: "chrome", password: null });
+ renderView();
+
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+ expect(rfb.instances.length).toBe(0);
+
+ await userEvent.type(screen.getByLabelText("VNC password"), "typed");
+ await userEvent.click(screen.getByRole("button", { name: "Connect" }));
+
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("credentialsrequired");
+ expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "typed" });
+
+ await emit("connect");
+ await userEvent.click(screen.getByRole("button", { name: 'Save for all "chrome"' }));
+ expect(localStorage.getItem(NAME_PREFIX + "chrome")).toBe("typed");
+ });
+
+ it("saves for the current browser version", async () => {
+ mockFetch({ browserName: "chrome", browserVersion: "123", password: null });
+ renderView();
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+
+ await userEvent.type(screen.getByLabelText("VNC password"), "typed");
+ await userEvent.click(screen.getByRole("button", { name: "Connect" }));
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByRole("button", { name: 'Only "chrome 123"' }));
+ expect(localStorage.getItem(VERSION_PREFIX + "chrome@123")).toBe("typed");
+ });
+
+ it("prefers the version-saved password over the name-saved one", async () => {
+ localStorage.setItem(NAME_PREFIX + "chrome", "byname");
+ localStorage.setItem(VERSION_PREFIX + "chrome@123", "byversion");
+ mockFetch({ browserName: "chrome", browserVersion: "123" });
+ renderView();
+
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("credentialsrequired");
+ expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "byversion" });
+ });
+
+ it("does not persist when the user declines to save", async () => {
+ mockFetch({ browserName: "chrome", password: null });
+ renderView();
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+
+ await userEvent.type(screen.getByLabelText("VNC password"), "typed");
+ await userEvent.click(screen.getByRole("button", { name: "Connect" }));
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByRole("button", { name: "Don't save" }));
+ expect(localStorage.length).toBe(0);
+ expect(sessionStorage.length).toBe(0);
+ });
+
+ it("cancelling the prompt disconnects with a notice", async () => {
+ mockFetch({ browserName: "chrome", password: null });
+ renderView();
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.getByText("VNC authentication cancelled")).toBeInTheDocument();
+ });
+
+ it("does not require a browser name to fall back to manual save scope name", async () => {
+ mockFetch({ browserName: undefined, password: null });
+ renderView();
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+
+ await userEvent.type(screen.getByLabelText("VNC password"), "typed");
+ await userEvent.click(screen.getByRole("button", { name: "Connect" }));
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByRole("button", { name: 'Save for all ""' }));
+ expect(localStorage.length).toBe(0);
+ });
+});
+
+describe("securityfailure handling", () => {
+ it("exhausting the auto ladder opens the manual prompt with the failure reason", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+
+ await emit("securityfailure", { reason: "wrong password" });
+ await waitFor(() => expect(screen.getByText("VNC auth failed: wrong password")).toBeInTheDocument());
+ });
+
+ it("stops at the hard cap when manual entries keep failing", async () => {
+ mockFetch({ browserName: "chrome", password: "p0" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+
+ await emit("securityfailure");
+ await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument());
+
+ for (let i = 1; i <= 4; i++) {
+ await userEvent.type(screen.getByLabelText("VNC password"), `m${i}`);
+ await userEvent.click(screen.getByRole("button", { name: "Connect" }));
+ await waitFor(() => expect(rfb.instances.length).toBe(i + 1));
+ await emit("securityfailure", { reason: "nope" });
+ }
+
+ await waitFor(() => expect(screen.getByText("VNC auth failed: nope")).toBeInTheDocument());
+ expect(screen.queryByLabelText("VNC password")).not.toBeInTheDocument();
+ });
+});
+
+describe("disconnect handling", () => {
+ it("reports connection lost on an unclean disconnect", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+
+ await emit("disconnect", { clean: false });
+ expect(screen.getByText("Connection lost")).toBeInTheDocument();
+ });
+
+ it("ignores the disconnect that follows a securityfailure", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+
+ await emit("securityfailure", { reason: "bad" });
+ const firstInstance = rfb.instances[0];
+ await act(() => firstInstance.emit("disconnect", { clean: false }));
+ expect(screen.queryByText("Connection lost")).not.toBeInTheDocument();
+ });
+
+ it("shows a clean disconnect as disconnected", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+ await emit("disconnect", { clean: true });
+ expect(screen.getAllByText("Disconnected").length).toBeGreaterThan(0);
+ });
+});
+
+describe("window controls and clipboard", () => {
+ it("disconnects and navigates home on close", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ const inst = latest();
+
+ await userEvent.click(screen.getByTitle("Close"));
+ expect(inst.disconnect).toHaveBeenCalled();
+ expect(navigateMock).toHaveBeenCalledWith("/ui/");
+ });
+
+ it("requests desktop size when maximized", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Fullscreen"));
+ expect(latest().requestDesktopSize).toHaveBeenCalled();
+ });
+
+ it("scales the viewport on window resize when not maximized", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ latest().scaleViewport = false;
+ await act(() => {
+ window.dispatchEvent(new Event("resize"));
+ });
+ expect(latest().scaleViewport).toBe(true);
+ });
+
+ it("copies remote clipboard to local when available", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+ await emit("clipboard", { text: "remote-text" });
+
+ await userEvent.click(screen.getByTitle("Copy from VNC"));
+ expect(writeText).toHaveBeenCalledWith("remote-text");
+ });
+
+ it("pastes local clipboard into the session when readText is available", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ const readText = vi.fn().mockResolvedValue("local-text");
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Copy to VNC"));
+ await waitFor(() => expect(latest().clipboardPasteFrom).toHaveBeenCalledWith("local-text"));
+ });
+
+ it("falls back to the paste prompt when readText is unavailable", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Copy to VNC"));
+ const prompt = screen.getByText("Paste text to send to remote");
+ expect(prompt).toBeInTheDocument();
+
+ const input = document.querySelector(".clipboard-prompt-input") as HTMLTextAreaElement;
+ await userEvent.type(input, "manual paste");
+ await userEvent.click(screen.getByTitle("Send"));
+ expect(latest().clipboardPasteFrom).toHaveBeenCalledWith("manual paste");
+ });
+
+ it("cancels the paste prompt", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Copy to VNC"));
+ const promptActions = document.querySelector(".clipboard-prompt-actions") as HTMLElement;
+ await userEvent.click(within(promptActions).getByTitle("Close"));
+ await waitFor(() => expect(screen.queryByText("Paste text to send to remote")).not.toBeInTheDocument());
+ });
+
+ it("does nothing when copying from an empty remote clipboard", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ const writeText = vi.fn();
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Copy from VNC"));
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it("writes to the clipboard via execCommand fallback when writeText is unavailable", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} });
+ const execCommand = vi.fn().mockReturnValue(true);
+ (document as any).execCommand = execCommand;
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+ await emit("clipboard", { text: "remote-text" });
+
+ await userEvent.click(screen.getByTitle("Copy from VNC"));
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ });
+
+ it("falls back to execCommand when writeText rejects", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ const writeText = vi.fn().mockRejectedValue(new Error("denied"));
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+ const execCommand = vi.fn(() => {
+ throw new Error("execCommand unsupported");
+ });
+ (document as any).execCommand = execCommand;
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+ await emit("clipboard", { text: "remote-text" });
+
+ await userEvent.click(screen.getByTitle("Copy from VNC"));
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ });
+
+ it("reads via the textarea fallback when readText rejects, tolerating sendKey errors", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ const readText = vi.fn().mockRejectedValue(new Error("denied"));
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+ latest().sendKey = vi.fn(() => {
+ throw new Error("boom");
+ });
+
+ const hidden = document.querySelector('textarea[tabindex="-1"]') as HTMLTextAreaElement;
+ let pasteHandler: ((e: any) => void) | undefined;
+ const origAdd = hidden.addEventListener.bind(hidden);
+ vi.spyOn(hidden, "addEventListener").mockImplementation((type: string, handler: any, opts?: any) => {
+ if (type === "paste") pasteHandler = handler;
+ return origAdd(type, handler, opts);
+ });
+
+ void userEvent.click(screen.getByTitle("Copy to VNC"));
+ await waitFor(() => expect(pasteHandler).toBeDefined());
+
+ await act(async () => {
+ pasteHandler!({ clipboardData: { getData: () => "pasted-text" } });
+ });
+
+ await waitFor(() => expect(latest().clipboardPasteFrom).toHaveBeenCalledWith("pasted-text"));
+ });
+
+ it("does not paste when the local clipboard is empty", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ const readText = vi.fn().mockResolvedValue("");
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Copy to VNC"));
+ await waitFor(() => expect(readText).toHaveBeenCalled());
+ expect(latest().clipboardPasteFrom).not.toHaveBeenCalled();
+ });
+
+ it("captures pasted text in the paste prompt textarea", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} });
+
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Copy to VNC"));
+ const input = document.querySelector(".clipboard-prompt-input") as HTMLTextAreaElement;
+ await act(async () => {
+ const pasteEvent = new Event("paste", { bubbles: true }) as any;
+ pasteEvent.clipboardData = { getData: () => "from-paste" };
+ input.dispatchEvent(pasteEvent);
+ });
+ expect((input as HTMLTextAreaElement).value).toBe("from-paste");
+ });
+
+ it("toggling fullscreen off scales the viewport again", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Fullscreen"));
+ latest().scaleViewport = false;
+ await userEvent.click(screen.getByTitle("Fullscreen"));
+ expect(latest().scaleViewport).toBe(true);
+ });
+
+ it("requests desktop size on window resize while maximized", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await userEvent.click(screen.getByTitle("Fullscreen"));
+ latest().requestDesktopSize.mockClear();
+ await act(async () => {
+ window.dispatchEvent(new Event("resize"));
+ });
+ expect(latest().requestDesktopSize).toHaveBeenCalled();
+ });
+
+ it("disconnects the session on page unload", async () => {
+ mockFetch({ browserName: "chrome", password: "secret" });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ const inst = latest();
+
+ await act(async () => {
+ window.dispatchEvent(new Event("beforeunload"));
+ });
+ expect(inst.disconnect).toHaveBeenCalled();
+ });
+
+ it("keeps ticking the uptime while connected", async () => {
+ mockFetch({ browserName: "chrome", password: "secret", startTime: new Date(Date.now() - 5000).toISOString() });
+ renderView();
+ await waitFor(() => expect(rfb.instances.length).toBe(1));
+ await emit("connect");
+
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 1100));
+ });
+ expect(screen.getByText(/Uptime:/)).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/VNCView.tsx b/src/pages/VNCView.tsx
index ea52241..caf04f8 100644
--- a/src/pages/VNCView.tsx
+++ b/src/pages/VNCView.tsx
@@ -1,7 +1,21 @@
import React, { useEffect, useRef, useState } from "react";
-import { useParams, Link, useNavigate, useLocation } from "react-router-dom";
+import { useParams, useNavigate, useLocation } from "react-router-dom";
import RFB from "@novnc/novnc/lib/rfb";
import { formatUptime } from "../utils";
+import {
+ newAttemptState,
+ buildLadder,
+ nextCandidate,
+ registerFailure,
+ isHardCapReached,
+} from "../lib/vncAuth";
+import {
+ getSavedByName,
+ setSavedByName,
+ getSavedByVersion,
+ setSavedByVersion,
+} from "../lib/vncStore";
+import { PasswordPromptModal, SavePasswordModal, type SaveScope } from "../components/VncPasswordModals";
import copyToVncIcon from "../assets/icons/clipboard-paste.svg";
import copyFromVncIcon from "../assets/icons/clipboard-copy.svg";
import sendIcon from "../assets/icons/send.svg";
@@ -31,42 +45,39 @@ export const VNCView: React.FC = () => {
const [pasteText, setPasteText] = useState("");
const pasteInputRef = useRef(null);
- useEffect(() => {
- if (!sessionStartTime) return;
-
- const update = () => setUptime(formatUptime(sessionStartTime));
- update();
-
- const interval = setInterval(() => {
- if (status === "Disconnected") return;
- update();
- }, 1000);
-
- return () => clearInterval(interval);
- }, [sessionStartTime, status]);
-
- useEffect(() => {
- if (!id) return;
-
- if (sessionStartTime) return;
-
- fetch(`/api/v1/browsers/${id}`)
- .then((r) => {
- if (!r.ok) throw new Error('Failed to fetch session');
- return r.json();
- })
- .then((session: { browserId: string; startTime: string }) => {
- if (session.browserId === id) {
- setSessionStartTime(session.startTime);
- }
- })
- .catch(() => {
- });
- }, [id, sessionStartTime]);
+ const browserNameRef = useRef("");
+ const browserVersionRef = useRef("");
+ const attemptRef = useRef(newAttemptState());
+ const ladderRef = useRef([]);
+ const pendingPwRef = useRef("");
+ const manualRef = useRef(false);
+ const securityFailedRef = useRef(false);
+ const isMaximizedRef = useRef(false);
+ const createRfbRef = useRef<(password: string, manual: boolean) => void>(() => {});
+
+ const [showPrompt, setShowPrompt] = useState(false);
+ const [promptError, setPromptError] = useState(undefined);
+ const [showSave, setShowSave] = useState(false);
+ const [notice, setNotice] = useState("");
+
+ const teardownRfb = () => {
+ const current = rfbRef.current;
+ rfbRef.current = null;
+ if (current) {
+ try {
+ current.disconnect();
+ } catch {}
+ }
+ };
- useEffect(() => {
+ createRfbRef.current = (password: string, manual: boolean) => {
if (!id || !containerRef.current) return;
- if (rfbRef.current) return;
+
+ teardownRfb();
+ manualRef.current = manual;
+ pendingPwRef.current = password;
+ securityFailedRef.current = false;
+ setStatus("Connecting");
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
@@ -77,37 +88,115 @@ export const VNCView: React.FC = () => {
resizeSession: false,
});
- rfb.addEventListener("credentialsrequired", async () => {
- try {
- const resp = await fetch(`/api/v1/browsers/${id}/vnc/settings`);
- if (!resp.ok) throw new Error("failed to get password");
- const { password } = await resp.json();
-
- if (typeof rfb.sendCredentials === "function") {
- rfb.sendCredentials({ password });
- } else {
- (rfb as any)._rfb_credentials = { password };
- }
- } catch {
- setStatus("Disconnected");
- try { rfb.disconnect(); } catch {}
+ rfb.addEventListener("credentialsrequired", () => {
+ const pw = pendingPwRef.current;
+ if (typeof rfb.sendCredentials === "function") {
+ rfb.sendCredentials({ password: pw });
+ } else {
+ (rfb as any)._rfb_credentials = { password: pw };
}
});
-
rfb.viewOnly = false;
rfb.localCursor = true;
rfb.clipViewport = false;
rfb.viewportDrag = false;
- rfb.addEventListener("connect", () => setStatus("Connected"));
- rfb.addEventListener("disconnect", () => setStatus("Disconnected"));
+ rfb.addEventListener("connect", () => {
+ setStatus("Connected");
+ setNotice("");
+ if (manualRef.current) {
+ setShowSave(true);
+ }
+ });
+
+ rfb.addEventListener("securityfailure", (event: any) => {
+ securityFailedRef.current = true;
+ const reason = event?.detail?.reason ?? "authentication failed";
+ attemptRef.current = registerFailure(attemptRef.current, pendingPwRef.current);
+ if (isHardCapReached(attemptRef.current)) {
+ setStatus("Disconnected");
+ setNotice(`VNC auth failed: ${reason}`);
+ return;
+ }
+ const next = nextCandidate(ladderRef.current, attemptRef.current.tried);
+ if (next !== null) {
+ createRfbRef.current(next, false);
+ return;
+ }
+ manualRef.current = true;
+ setPromptError(`VNC auth failed: ${reason}`);
+ setShowPrompt(true);
+ });
+
+ rfb.addEventListener("disconnect", (event: any) => {
+ if (securityFailedRef.current) return;
+ setStatus("Disconnected");
+ if (event?.detail?.clean === false) {
+ setNotice("Connection lost");
+ }
+ });
+
rfb.addEventListener("clipboard", (event: any) => {
const text = event?.detail?.text ?? "";
setLastRemoteClipboard(text);
});
rfbRef.current = rfb;
+ };
+
+ useEffect(() => {
+ if (!sessionStartTime) return;
+
+ const update = () => setUptime(formatUptime(sessionStartTime));
+ update();
+
+ const interval = setInterval(() => {
+ if (status === "Disconnected") return;
+ update();
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [sessionStartTime, status]);
+
+ useEffect(() => {
+ if (!id || !containerRef.current) return;
+ if (rfbRef.current) return;
+
+ let cancelled = false;
+
+ const start = async () => {
+ try {
+ const resp = await fetch(`/api/v1/browsers/${id}`);
+ if (resp.ok) {
+ const session: { browserId: string; browserName?: string; browserVersion?: string; startTime?: string } = await resp.json();
+ if (session.browserId === id) {
+ browserNameRef.current = session.browserName ?? "";
+ browserVersionRef.current = session.browserVersion ?? "";
+ if (!sessionStartTime && session.startTime) {
+ setSessionStartTime(session.startTime);
+ }
+ }
+ }
+ } catch {}
+
+ if (cancelled) return;
+
+ ladderRef.current = buildLadder(
+ getSavedByVersion(browserNameRef.current, browserVersionRef.current),
+ getSavedByName(browserNameRef.current),
+ );
+
+ const first = nextCandidate(ladderRef.current, attemptRef.current.tried);
+ if (first !== null) {
+ createRfbRef.current(first, false);
+ } else {
+ manualRef.current = true;
+ setShowPrompt(true);
+ }
+ };
+
+ start();
const resizeCanvas = () => {
if (!rfbRef.current || !containerRef.current) return;
@@ -117,32 +206,36 @@ export const VNCView: React.FC = () => {
const h = Math.round(rect.height);
try {
- if (isMaximized && typeof rfbRef.current.requestDesktopSize === "function") {
+ if (isMaximizedRef.current && typeof rfbRef.current.requestDesktopSize === "function") {
rfbRef.current.requestDesktopSize(w, h);
} else {
rfbRef.current.scaleViewport = true;
}
- } catch { }
+ } catch {}
};
window.addEventListener("resize", resizeCanvas);
- setTimeout(resizeCanvas, 200);
+ const resizeTimer = setTimeout(resizeCanvas, 200);
const handleUnload = () => {
try {
rfbRef.current?.disconnect();
- } catch { }
+ } catch {}
};
window.addEventListener("beforeunload", handleUnload);
return () => {
+ cancelled = true;
+ clearTimeout(resizeTimer);
window.removeEventListener("resize", resizeCanvas);
window.removeEventListener("beforeunload", handleUnload);
+ teardownRfb();
};
}, [id]);
useEffect(() => {
+ isMaximizedRef.current = isMaximized;
if (!rfbRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
@@ -155,14 +248,34 @@ export const VNCView: React.FC = () => {
} else {
rfbRef.current.scaleViewport = true;
}
- } catch { }
+ } catch {}
}, [isMaximized]);
- const handleClose = () => {
- try {
- rfbRef.current?.disconnect();
- } catch { }
+ const handlePromptSubmit = (password: string) => {
+ setShowPrompt(false);
+ setPromptError(undefined);
+ createRfbRef.current(password, true);
+ };
+ const handlePromptCancel = () => {
+ setShowPrompt(false);
+ teardownRfb();
+ setStatus("Disconnected");
+ setNotice("VNC authentication cancelled");
+ };
+
+ const handleSaveChoose = (scope: SaveScope) => {
+ const pw = pendingPwRef.current;
+ if (scope === "name") {
+ setSavedByName(browserNameRef.current, pw);
+ } else if (scope === "version") {
+ setSavedByVersion(browserNameRef.current, browserVersionRef.current, pw);
+ }
+ setShowSave(false);
+ };
+
+ const handleClose = () => {
+ teardownRfb();
navigate("/ui/");
};
@@ -252,24 +365,6 @@ export const VNCView: React.FC = () => {
input.select();
}, [showPastePrompt]);
- const submitPasteText = (text: string) => {
- const value = text.trim();
- if (value && rfbRef.current?.clipboardPasteFrom) {
- rfbRef.current.clipboardPasteFrom(value);
- try {
- rfbRef.current.sendKey(0xFFE3, "ControlLeft", true);
- rfbRef.current.sendKey(0x0076, "KeyV", true);
- rfbRef.current.sendKey(0x0076, "KeyV", false);
- rfbRef.current.sendKey(0xFFE3, "ControlLeft", false);
- } catch {
- }
- setClipboardHint("Pasted to VNC");
- setTimeout(() => setClipboardHint(""), 2000);
- }
- setShowPastePrompt(false);
- setPasteText("");
- };
-
const handlePastePromptChange = (event: React.ChangeEvent) => {
const value = event.target.value;
setPasteText(value);
@@ -378,7 +473,9 @@ export const VNCView: React.FC = () => {
{status !== "Connected" && (
- {status === "Connecting" ? "Session Loading..." : "Session Disconnected"}
+ {status === "Connecting"
+ ? "Session Loading..."
+ : notice || "Session Disconnected"}
)}
{clipboardHint && (
@@ -409,6 +506,21 @@ export const VNCView: React.FC = () => {
+
+ {showPrompt && (
+
+ )}
+ {showSave && (
+
+ )}
>
);
};
diff --git a/src/vitest.config.ts b/src/vitest.config.ts
new file mode 100644
index 0000000..b8bbfa3
--- /dev/null
+++ b/src/vitest.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ define: {
+ __BUILD_NUMBER__: JSON.stringify("test"),
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest.setup.ts"],
+ css: false,
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "text-summary"],
+ include: ["lib/**/*.ts", "components/**/*.tsx", "pages/VNCView.tsx"],
+ exclude: ["**/*.test.ts", "**/*.test.tsx"],
+ },
+ },
+});
diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts
new file mode 100644
index 0000000..f149f27
--- /dev/null
+++ b/src/vitest.setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";