diff --git a/src/main.tsx b/src/main.tsx
index ea0ed8e93..9e7090dc1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -6,6 +6,8 @@ import "./ui/index.css";
import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron";
+import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
+import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner";
import {
@@ -84,6 +86,15 @@ function FullscreenApp() {
}
}
+/**
+ * A signed-out browser gets the normal login for a full-screen link and comes
+ * back to it afterwards. The desktop app and the embedded sign-in frames
+ * hand a login to their own shell, so they keep the bare view.
+ */
+function signsInForViewLink(): boolean {
+ return !isElectron() && !isEmbeddedAuthFrame();
+}
+
function FullscreenAppGate() {
const { t } = useTranslation();
const [ready, setReady] = useState(false);
@@ -109,7 +120,16 @@ function FullscreenAppGate() {
if (!cancelled) setReady(true);
})
.catch(() => {
- if (!cancelled) setAuthFailed(true);
+ if (cancelled) return;
+ if (signsInForViewLink()) {
+ rememberViewLink();
+ window.history.replaceState(
+ {},
+ document.title,
+ window.location.pathname,
+ );
+ }
+ setAuthFailed(true);
});
return () => {
@@ -118,7 +138,7 @@ function FullscreenAppGate() {
}, []);
if (authFailed) {
- return ;
+ return signsInForViewLink() ? : ;
}
if (!ready) {
@@ -181,6 +201,7 @@ function App() {
// Non-fatal: WebSocket connections will fall back to cookie auth
}
}
+ if (resumeViewLink()) return;
setPhase("loading-app");
await Promise.all([
startPluginRuntime()
@@ -262,6 +283,10 @@ function App() {
function handleLogin(u: string) {
loggingOutRef.current = false;
clearDesktopManualLogout();
+ if (resumeViewLink()) {
+ setPhase("loading-app");
+ return;
+ }
setAuthUsername(u);
setPhase("loading-app");
void (async () => {
diff --git a/src/ui/lib/view-link-resume.ts b/src/ui/lib/view-link-resume.ts
new file mode 100644
index 000000000..ed39c72aa
--- /dev/null
+++ b/src/ui/lib/view-link-resume.ts
@@ -0,0 +1,56 @@
+/**
+ * Brings a signed-out full-screen `?view=` link back after sign-in. The link
+ * waits in sessionStorage, which survives a redirect login in the same tab,
+ * so no return path is ever sent to the server.
+ */
+
+const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
+
+/** The value as a same-origin path with a `view` parameter, or null. */
+export function toViewLinkPath(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ if (!value.startsWith("/") || value.startsWith("//")) return null;
+ let url: URL;
+ try {
+ url = new URL(value, window.location.origin);
+ } catch {
+ return null;
+ }
+ if (url.origin !== window.location.origin) return null;
+ if (!url.searchParams.get("view")) return null;
+ return `${url.pathname}${url.search}${url.hash}`;
+}
+
+export function rememberViewLink(): void {
+ const { pathname, search, hash } = window.location;
+ const path = toViewLinkPath(`${pathname}${search}${hash}`);
+ if (!path) return;
+ try {
+ sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
+ } catch {
+ // Without storage the user just lands on the app after signing in.
+ }
+}
+
+/** The remembered link, cleared whether or not it is still valid. */
+export function consumeViewLink(): string | null {
+ try {
+ const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
+ sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
+ return toViewLinkPath(stored);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Opens the remembered link, if there is one. The navigation waits a tick so
+ * history updates the login callback makes right after sign-in cannot cancel
+ * it.
+ */
+export function resumeViewLink(): boolean {
+ const path = consumeViewLink();
+ if (!path) return false;
+ setTimeout(() => window.location.replace(path), 0);
+ return true;
+}
diff --git a/src/ui/tests/lib/view-link-resume.test.ts b/src/ui/tests/lib/view-link-resume.test.ts
new file mode 100644
index 000000000..ecf37509c
--- /dev/null
+++ b/src/ui/tests/lib/view-link-resume.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { consumeViewLink, toViewLinkPath } from "@/lib/view-link-resume";
+
+describe("toViewLinkPath", () => {
+ it.each([
+ ["/?view=example-plugin.request&request=123", true],
+ ["/termix/?view=example-plugin.request#top", true],
+ ["//evil.com/?view=x", false],
+ ["/\\evil.com/?view=x", false],
+ ["https://evil.com/?view=x", false],
+ ["/foo", false],
+ ["/?view=", false],
+ ["not a url", false],
+ ])("%s -> %s", (value, accepted) => {
+ expect(toViewLinkPath(value)).toBe(accepted ? value : null);
+ });
+});
+
+describe("consumeViewLink", () => {
+ it("clears the stored link even when it is invalid", () => {
+ sessionStorage.setItem("termix-pending-view-link", "//evil.com/?view=x");
+ expect(consumeViewLink()).toBeNull();
+ expect(sessionStorage.getItem("termix-pending-view-link")).toBeNull();
+ });
+});
diff --git a/src/ui/tests/main.test.tsx b/src/ui/tests/main.test.tsx
new file mode 100644
index 000000000..685c1d663
--- /dev/null
+++ b/src/ui/tests/main.test.tsx
@@ -0,0 +1,186 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const KEY = "termix-pending-view-link";
+
+const mocks = vi.hoisted(() => ({
+ rendered: null as ReactNode,
+ isElectron: vi.fn(() => false),
+ getStoredAuth: vi.fn(),
+ getUserInfo: vi.fn(),
+ fetchGuestViews: vi.fn(),
+ startPluginRuntime: vi.fn(async () => {}),
+ standaloneViewFor: vi.fn(),
+}));
+
+vi.mock("react-dom/client", () => ({
+ createRoot: () => ({
+ render: (node: ReactNode) => {
+ mocks.rendered = node;
+ },
+ }),
+}));
+vi.mock("@/lib/client-cache-version", () => ({
+ prepareClientCacheVersion: () => Promise.resolve(),
+}));
+vi.mock("@/i18n/i18n", () => ({}));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock("@/components/theme-provider", () => ({
+ ThemeProvider: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("@/contexts/BrandingContext", () => ({
+ BrandingProvider: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("@/contexts/UiPreferencesContext", () => ({
+ UiPreferencesProvider: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("@/components/sonner", () => ({ Toaster: () => null }));
+vi.mock("@/hooks/use-service-worker", () => ({ useServiceWorker: () => {} }));
+vi.mock("@/lib/electron-wheel-zoom", () => ({
+ installElectronWheelZoomGuard: () => {},
+}));
+vi.mock("@/lib/electron", () => ({ isElectron: mocks.isElectron }));
+vi.mock("@/lib/embedded-server-status", () => ({
+ getEmbeddedServerFailure: async () => null,
+}));
+vi.mock("@/auth/Auth", () => ({
+ Auth: ({ onLogin }: { onLogin: (username: string) => void }) => (
+
+ ),
+ getStoredAuth: mocks.getStoredAuth,
+ clearStoredAuth: vi.fn(),
+ markDesktopManualLogout: vi.fn(),
+ clearDesktopManualLogout: vi.fn(),
+}));
+vi.mock("@/main-axios", () => ({
+ getUserInfo: mocks.getUserInfo,
+ getCurrentToken: vi.fn(async () => null),
+ appReadyPromise: Promise.resolve(),
+}));
+vi.mock("@/plugin-host/loader", () => ({
+ fetchGuestViews: mocks.fetchGuestViews,
+ startPluginRuntime: mocks.startPluginRuntime,
+ stopPluginRuntime: vi.fn(async () => {}),
+}));
+vi.mock("@/plugin-host/plugin-store", () => ({
+ settledPromise: async () => {},
+}));
+vi.mock("@/plugin-host/shell-bridge", () => ({ resetShellBridge: vi.fn() }));
+vi.mock("@/hooks/use-permissions", () => ({
+ preloadPermissions: async () => {},
+}));
+vi.mock("@/api/open-tabs-api", () => ({
+ getUserPreferences: async () => undefined,
+}));
+vi.mock("@/shell/tab-registry", () => ({
+ standaloneViewFor: mocks.standaloneViewFor,
+}));
+vi.mock("@/plugin-host/PluginViewPlaceholder", () => ({
+ PluginViewPlaceholder: ({ viewId }: { viewId: string }) => (
+
placeholder:{viewId}
+ ),
+}));
+vi.mock("@/onboarding/startup", () => ({
+ resolveStartupOnboarding: async () => null,
+}));
+vi.mock("@/AppShell", () => ({ AppShell: () => app shell
}));
+vi.mock("@/onboarding/OnboardingStage", () => ({
+ OnboardingStage: () => null,
+}));
+
+const originalLocation = window.location;
+let replace: ReturnType;
+
+/** Loads the entry point fresh, the way a page load would. */
+async function openPage(url: string) {
+ window.history.replaceState({}, "", url);
+ vi.resetModules();
+ mocks.rendered = null;
+ await import("../../main");
+ await vi.waitFor(() => expect(mocks.rendered).not.toBeNull());
+ render(<>{mocks.rendered}>);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ mocks.isElectron.mockReturnValue(false);
+ mocks.getStoredAuth.mockReturnValue(null);
+ mocks.getUserInfo.mockRejectedValue({ status: 401 });
+ mocks.fetchGuestViews.mockResolvedValue(["example-plugin.guest"]);
+ mocks.standaloneViewFor.mockImplementation((view: string) => ({
+ standalone: () => standalone:{view}
,
+ }));
+ replace = vi.fn();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: {
+ get origin() {
+ return originalLocation.origin;
+ },
+ get pathname() {
+ return originalLocation.pathname;
+ },
+ get search() {
+ return originalLocation.search;
+ },
+ get hash() {
+ return originalLocation.hash;
+ },
+ replace,
+ },
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: originalLocation,
+ });
+});
+
+describe("full-screen view links", () => {
+ it("shows the login when signed out and returns to the link after it", async () => {
+ await openPage("/?view=example-plugin.request&request=123");
+
+ const signIn = await screen.findByText("sign in");
+ expect(sessionStorage.getItem(KEY)).toBe(
+ "/?view=example-plugin.request&request=123",
+ );
+ fireEvent.click(signIn);
+
+ await vi.waitFor(() =>
+ expect(replace).toHaveBeenCalledWith(
+ "/?view=example-plugin.request&request=123",
+ ),
+ );
+ expect(sessionStorage.getItem(KEY)).toBeNull();
+ });
+
+ it("keeps the bare view in the desktop app", async () => {
+ mocks.isElectron.mockReturnValue(true);
+ mocks.standaloneViewFor.mockReturnValue(undefined);
+ await openPage("/?view=example-plugin.request");
+
+ expect(
+ await screen.findByText("placeholder:example-plugin.request"),
+ ).toBeTruthy();
+ expect(screen.queryByText("sign in")).toBeNull();
+ expect(sessionStorage.getItem(KEY)).toBeNull();
+ });
+
+ it("serves a guest view without a session or a login", async () => {
+ await openPage("/?view=example-plugin.guest&room=1");
+
+ expect(
+ await screen.findByText("standalone:example-plugin.guest"),
+ ).toBeTruthy();
+ expect(mocks.getUserInfo).not.toHaveBeenCalled();
+ expect(sessionStorage.getItem(KEY)).toBeNull();
+ });
+});