Implement frontend session routing flow

- Validate and prepare access links in apps/frontend
- Add session, ended, and unauthorized routes with polling
- Copy full public access URLs from the admin links page
This commit is contained in:
user
2026-03-28 19:10:24 +02:00
parent 31a501f75b
commit 92deee1b2e
16 changed files with 392 additions and 119 deletions

View File

@@ -4,7 +4,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --port 5173",
"dev": "vite dev --port 5174",
"build": "NODE_ENV=build vite build",
"prod": "HOST=0.0.0.0 PORT=3000 node ./build/index.js",
"preview": "vite preview",

View File

@@ -1,6 +1,6 @@
import { PUBLIC_WS_SCRCPY_SVC_URL } from "$env/static/public";
import { env } from "$env/dynamic/public";
export const WS_SCRCPY_URL = PUBLIC_WS_SCRCPY_SVC_URL;
export const WS_SCRCPY_URL = env.PUBLIC_WS_SCRCPY_SVC_URL ?? "";
export const TRANSITION_COLORS = "transition-colors duration-150 ease-in-out";
export const TRANSITION_ALL = "transition-all duration-150 ease-in-out";

View File

@@ -0,0 +1,39 @@
import { resolveLinkSQ } from "./link.remote";
import type { Err } from "@pkg/result";
export type SessionEndReason =
| "missing_token"
| "invalid"
| "expired"
| "revoked"
| "busy"
| "not_found"
| "network"
| "unknown";
export async function resolveLinkFresh(token: string) {
const query = resolveLinkSQ({ token });
if (query.ready) {
await query.refresh();
return query.current;
}
return await query;
}
export function mapLinkErrorToSessionEndReason(
error?: Pick<Err, "code" | "message" | "description"> | null,
): SessionEndReason {
if (!error) return "unknown";
const text = `${error.code} ${error.message} ${error.description}`.toLowerCase();
if (text.includes("expired")) return "expired";
if (text.includes("revoked")) return "revoked";
if (text.includes("not found")) return "not_found";
if (text.includes("busy") || text.includes("offline")) return "busy";
if (text.includes("invalid") || text.includes("token")) return "invalid";
return "unknown";
}

View File

@@ -12,6 +12,7 @@ import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
import { DeviceStatus } from "@pkg/logic/domains/device/data";
import { ERROR_CODES, type Err } from "@pkg/result";
import { settings } from "@pkg/settings";
import { WS_SCRCPY_URL } from "$lib/core/constants";
const lc = getLinkController();
@@ -35,6 +36,25 @@ type LinkSessionShape = {
};
};
const SCRCPY_SERVER_PORT = 8886;
function buildStreamUrl(host: string, wsPort: string): string | null {
const trimmedHost = host.trim();
const trimmedPort = wsPort.trim();
if (!trimmedHost || !trimmedPort) return null;
const udid = `${trimmedHost}:${trimmedPort}`;
const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(
/^http:/,
"ws:",
);
const wsParam =
`${baseWss}/?action=proxy-adb&remote=${encodeURIComponent(`tcp:${SCRCPY_SERVER_PORT}`)}&udid=${encodeURIComponent(udid)}`;
return `${WS_SCRCPY_URL}/#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;
}
async function getPreparedLinkContext(fctx: FlowExecCtx, token: string) {
const linkResult = await lc.validate(fctx, token);
if (linkResult.isErr()) {
@@ -119,6 +139,7 @@ export async function resolveLinkFlow(fctx: FlowExecCtx, token: string) {
isAvailable:
link.device.status === DeviceStatus.ONLINE &&
!link.device.inUse,
streamUrl: buildStreamUrl(link.device.host, link.device.wsPort),
},
supportedApp: link.supportedApp,
},

View File

@@ -1,7 +1,81 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
mapLinkErrorToSessionEndReason,
resolveLinkFresh,
} from "$lib/domains/link/client";
import { prepareLinkSC } from "$lib/domains/link/link.remote";
import { onMount } from "svelte";
onMount(() => {});
let state: "boot" | "validating" | "preparing" = "boot";
let statusText = "Initializing session...";
onMount(() => {
const run = async () => {
const token =
new URLSearchParams(window.location.search)
.get("token")
?.trim() || "";
if (!token) {
await goto("/unauthorized?reason=missing_token", {
replaceState: true,
});
return;
}
state = "validating";
statusText = "Validating your access link...";
const resolved = await resolveLinkFresh(token);
if (resolved?.error || !resolved?.data) {
const reason = mapLinkErrorToSessionEndReason(resolved?.error);
await goto(
`/session-ended?reason=${encodeURIComponent(reason)}`,
{
replaceState: true,
},
);
return;
}
state = "preparing";
statusText = "Preparing your app session...";
const prepared = await prepareLinkSC({ token });
if (prepared?.error || !prepared?.data) {
const reason = mapLinkErrorToSessionEndReason(prepared?.error);
await goto(
`/session-ended?reason=${encodeURIComponent(reason)}`,
{
replaceState: true,
},
);
return;
}
await goto(`/session?token=${encodeURIComponent(token)}`, {
replaceState: true,
});
};
void run();
});
</script>
<span>base page to show the loading state</span>
<main
class="bg-background text-foreground flex min-h-screen items-center justify-center px-6"
>
<section
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
>
<h1 class="text-xl font-semibold">Starting your session</h1>
<p class="mt-3 text-sm text-muted-foreground">{statusText}</p>
<div class="mt-5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="bg-primary h-full animate-pulse"
style={`width: ${state === "boot" ? "35%" : state === "validating" ? "65%" : "88%"}`}
></div>
</div>
</section>
</main>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { browser } from "$app/environment";
const reasonMap: Record<string, string> = {
revoked: "This session was revoked by an administrator.",
expired: "This session expired.",
busy: "The assigned device is not currently available.",
not_found: "This session link could not be found.",
invalid: "This session link is invalid.",
network: "Connection was lost. Please try again.",
unknown: "This session is no longer available.",
};
const reason = browser
? (new URLSearchParams(window.location.search).get("reason") ?? "unknown")
: "unknown";
</script>
<main class="bg-background text-foreground flex min-h-screen items-center justify-center px-6">
<section class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center">
<h1 class="text-xl font-semibold">Session ended</h1>
<p class="mt-3 text-sm text-muted-foreground">
{reasonMap[reason] || reasonMap.unknown}
</p>
</section>
</main>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
mapLinkErrorToSessionEndReason,
resolveLinkFresh,
} from "$lib/domains/link/client";
import { onMount } from "svelte";
const POLL_INTERVAL_MS = 2000;
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
let state: "loading" | "ready" = "loading";
let token = "";
let streamUrl: string | null = null;
let statusText = "Connecting to your active session...";
onMount(() => {
let disposed = false;
let timer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let consecutivePollErrors = 0;
const endSession = async (reason: string) => {
if (disposed) return;
if (timer) clearInterval(timer);
streamUrl = null;
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
replaceState: true,
});
};
const pollValidity = async () => {
if (disposed || inFlight || !token) return;
inFlight = true;
try {
const result = await resolveLinkFresh(token);
if (disposed) return;
if (result?.error || !result?.data) {
await endSession(
mapLinkErrorToSessionEndReason(result?.error),
);
return;
}
consecutivePollErrors = 0;
if (result.data.link.status !== "active") {
await endSession("expired");
return;
}
const nextStreamUrl = result.data.device.streamUrl;
if (typeof nextStreamUrl === "string" && nextStreamUrl.length > 0) {
streamUrl = nextStreamUrl;
}
} catch {
consecutivePollErrors += 1;
if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
await endSession("network");
}
} finally {
inFlight = false;
}
};
const start = async () => {
token =
new URLSearchParams(window.location.search)
.get("token")
?.trim() || "";
if (!token) {
await goto("/unauthorized?reason=missing_token", {
replaceState: true,
});
return;
}
const initial = await resolveLinkFresh(token);
if (initial?.error || !initial?.data) {
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
return;
}
streamUrl = initial.data.device.streamUrl;
state = "ready";
statusText = "Session active";
await pollValidity();
timer = setInterval(() => {
void pollValidity();
}, POLL_INTERVAL_MS);
};
void start();
return () => {
disposed = true;
if (timer) clearInterval(timer);
};
});
</script>
<main class="bg-background text-foreground min-h-screen">
{#if state === "loading"}
<section class="flex min-h-screen items-center justify-center px-6">
<div
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
>
<h1 class="text-xl font-semibold">Loading session</h1>
<p class="mt-3 text-sm text-muted-foreground">
{statusText}
</p>
</div>
</section>
{:else}
<section class="flex min-h-screen flex-col">
<div class="flex items-center justify-center border-b border-border px-3 py-2">
<p class="text-xs text-muted-foreground">{statusText}</p>
</div>
<div class="bg-black flex-1">
{#if streamUrl}
<iframe
src={streamUrl}
title="Android session"
class="h-full w-full border-0"
allow="autoplay; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{:else}
<div class="flex h-full items-center justify-center">
<p class="text-sm text-white/70">Waiting for stream...</p>
</div>
{/if}
</div>
</section>
{/if}
</main>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { browser } from "$app/environment";
const reasonMap: Record<string, string> = {
missing_token: "Missing access token",
invalid: "Invalid access link",
};
const reason = browser
? (new URLSearchParams(window.location.search).get("reason") ?? "invalid")
: "invalid";
</script>
<main class="bg-background text-foreground flex min-h-screen items-center justify-center px-6">
<section class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center">
<h1 class="text-xl font-semibold">Access denied</h1>
<p class="mt-3 text-sm text-muted-foreground">
{reasonMap[reason] || "This session link is not valid."}
</p>
</section>
</main>

View File

@@ -1,8 +1,8 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import { PUBLIC_WS_SCRCPY_SVC_URL } from "$env/static/public";
import AppWindow from "@lucide/svelte/icons/app-window";
import { BellRingIcon, Link } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle";
import { env } from "$env/dynamic/public";
export type AppSidebarItem = {
title: string;
@@ -47,7 +47,7 @@ export const secondaryNavTree = [
},
] as AppSidebarItem[];
export const WS_SCRCPY_URL = PUBLIC_WS_SCRCPY_SVC_URL;
export const WS_SCRCPY_URL = env.PUBLIC_WS_SCRCPY_SVC_URL;
export const COMPANY_NAME = "SaaS Template";
export const WEBSITE_URL = "https://company.com";

View File

@@ -8,6 +8,7 @@
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Table from "$lib/components/ui/table";
import { env } from "$env/dynamic/public";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants";
import { linkVM } from "$lib/domains/link/link.vm.svelte";
@@ -49,10 +50,18 @@
if (success) resetForm();
}
async function copyToken(token: string) {
function buildPublicLink(token: string): string {
const base = env.PUBLIC_FRONTEND_URL?.trim() || "/";
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
const url = new URL(base, origin);
url.searchParams.set("token", token);
return url.toString();
}
async function copyLink(token: string) {
try {
await navigator.clipboard.writeText(token);
toast.success("Token copied to clipboard");
await navigator.clipboard.writeText(buildPublicLink(token));
toast.success("Link copied to clipboard");
} catch {
toast.error("Failed to copy");
}
@@ -145,7 +154,7 @@
<button
class="text-muted-foreground hover:text-foreground shrink-0"
onclick={() =>
copyToken(link.token)}
copyLink(link.token)}
>
<Copy class="h-3.5 w-3.5" />
</button>
@@ -270,7 +279,7 @@
<button
class="text-muted-foreground hover:text-foreground"
onclick={() =>
copyToken(link.token)}
copyLink(link.token)}
>
<Copy class="h-3.5 w-3.5" />
</button>