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>